From 7de831d60726055cbed8ef133efcc4f08152c795 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Sat, 17 Oct 2026 01:52:18 +0000 Subject: [PATCH 1/3] feat(codegen)!: compile what a schema root states, and route every arm it declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the canonical-format and codegen work developed on the sales-agent integration branch onto main as one change, plus four smaller fixes in the same surface. The generated tree here is REGENERATED from the generator in this commit, not copied from the branch it came from. ## Why this is one change rather than a series The generator and the types that consume it were co-developed. Grouping the source commits by subject gives eight themes, but they are interleaved rather than sequential: of 85 commits carrying reviewable content, the constraint reader/compiler theme spans positions 0..80 and the split-root theme spans 6..82, in 60 contiguous runs. Thirty-three files appear in more than one theme, three of them (`scripts/post_generate_fixes.py`, `types/_forward_compat.py`, `types/canonical_creative.py`) in five. Any thematic split would reorder commits that depend on each other, so the themes describe the work without being able to schedule it. The review burden is far smaller than the file count suggests, because most of it is the generator: hand-written src/ 60 files scripts/ 13 files generated output 250 files (follows from the generator; see below) ## What it does Compile a schema root's constraint content instead of a third of it: one reader for what a root states, a vocabulary the compiled constraints are written in, and a compiler that names an arm and plans a branch class. Split roots then get a shared public base (`Shape`), one dispatch hook mixed in rather than a copy per root, and construction dispatched as well as validation, so a public name resolves to the branch a document satisfies. Around that: `error.field` carries the JSONPath-lite the spec requires, taken from a located issue and resolved against the document the peer actually sent, never from the document root; recovery text is derived from the code the pin registers, deleting seventy literals that restated it; narrowing stops giving up on the inputs that most need it; and pin gaps and compatibility annotations are declared in the schema at the field rather than patched onto classes. ## The four smaller fixes - **Format references compare every parameter they declare.** `core/format-id.json` declares four parameters beyond the `(agent_url, id)` identity, and three helpers each named three of them in a hand-written tuple, so `pixel_ratio` was never compared: a product requiring 2x accepted a creative claiming no density, and accepted one claiming a conflicting density, while the legacy-size upgrade discarded a density it was handed. The set is now derived from `model_fields`. `pixel_ratio` keeps one deliberate rule, because the schema gives it one — omitting it selects the backward-compatible 1x variant, so an absent density reads as 1x rather than as unspecified. - **Open asset unions are generated, and route by a derived vocabulary.** The callable discriminator read a hand-written frozenset of 14 individual and 12 group types beside generated unions of 21 and 21, and it read that frozenset before pydantic reached the arms — so seven individual and nine group asset types the pinned schema declares parsed as the unknown fallback with their generated classes unused. The vocabulary now comes from the arms' own literals. - **A declaration's v1 refs are read in either spelling.** The generated `ProductFormatDeclaration` arms declare `v1_format_ref`; `canonical_creative.Format` exposes `legacy_format_refs` over private state. Five functions read the second and raised `AttributeError` on the first, which made the v2-to-v1 product projection unusable against the generated types, and `client.py` read it behind a `getattr(..., ())` default that answered "asserts no v1 refs" with no error. All six reads go through one accessor that refuses a type declaring neither. - **Four `Legacy*` names are re-exported from `adcp.types.legacy`.** `LegacyProposalTotalBudgetGuidance` and the three `LegacyUpdateMediaBuy{Success,Error,Submitted}Response` names were reachable only from `adcp.types.aliases`, so an adopter following the migration module's own docstring could not reach them. Note the first two are the same defect: a hand-written list beside a generated truth, where the hand-written one was short. They were found independently. ## Two of these tests were reconciled, not the refusal narrowed `test_canonical_boundary_serialization.py::...[parent]` and `test_canonical_creatives_rc3.py::test_primary_dump_cannot_emit_legacy_identity_from_private_state_or_extras` are from the branch this work came from, and both failed here. Each builds its fixture with `model_construct` -- deliberately, since the subject is what a DUMP can emit and the fixture is a document validation refuses by design -- and then reached its last assertion through `Envelope(catalog=...)` / `Container(declaration=...)`, which validates that fixture and is refused by the wrap validator on the canonical boundary base. Measured before changing anything, and measured twice independently by two people: the refusal fires on that exact document at TOP LEVEL (`Format.model_validate`, `Catalog.model_validate`) as well as nested. That is a fact about production behaviour rather than a preference about what the test should say, which is what makes editing our own test reviewable -- a reader who doubts it can re-run those two calls. Since the refusal is correct at top level it is not over-firing on re-validation, so narrowing it is out; and the document is never legal in the first place, because `model_construct` bypasses validation entirely. The expectation was always wrong and the older serialization path simply never reached the check. `test_canonical_creatives_rc3` was checked separately rather than assumed to share the cause. It does, and more plainly: that test's own first assertion is `pytest.raises(ValidationError, match="legacy creative identity")` on a normal construction, so it proves validation refuses this shape and then asks validation to accept it. **No production code was changed on either side** -- not the refusal, not `#1521`'s serialization path. Only the two tests' plumbing: each now builds its containing model with `model_construct`, like the four sibling parameters that already passed. `[parent]` is kept rather than deleted, because it is the only case covering "the serializer does not add a fourth pass over the full tree when nested", and its assertions are untouched -- `roots == 3` still fails if the strip stops running once per root, verified by breaking it to 99 and watching all five parameters fail. ## Not included, deliberately - **No spec-version move.** `ADCP_VERSION` stays at main's 3.2.1 and no file under `schemas/` or `_compliance/` is touched. The branch this came from also carried a 3.2.3 adoption; that overlaps #1479 (signed 3.2.4) on 1230 files and does not cherry-pick onto main, so it is left to that PR. - **No interpreter or lockfile policy.** The source branch pinned `.python-version` to 3.12 and committed a 3240-line `uv.lock`. Main has neither and its CI tests 3.10/3.11/3.13, so carrying an interpreter-pin proposal inside this change would invite rejection for a reason unrelated to the work. Excluded as a decision, not an oversight; it remains available as a separate proposal. ## Verification - `generate_versioned_stubs.py`, `generate_types.py`, `generate_versioned_bases.py` all pass `--check` after regeneration, so the 250 generated files follow from the generator in this commit rather than from the tree they were copied out of. - `ruff check src/` clean; `mypy src/adcp/` clean over 1538 modules. - Formatting: this branch leaves 7 files unformatted, all of which are unformatted on main too; it formats the two of main's nine that it touches. - Every public symbol and model shape a downstream consumer resolves is unchanged against main: 323 of 323 imported names resolve, and all 164 pydantic models carry an identical (field, required) set. BREAKING CHANGE: `adcp.types.response_dispatch` is removed, and with it `ResponseArmDispatchMixin`. A per-root copy of the dispatch hook is replaced by one mixed-in hook, `adcp.types._branch_dispatch.BranchDispatchMixin`, which the generated response classes now inherit directly -- so a consumer importing `from adcp.types.response_dispatch import ResponseArmDispatchMixin` must stop; nothing needs to be imported in its place, because the generated classes carry the behaviour themselves. `adcp.types._ergonomic` is removed in the same way, with the one coercion that was live declared at its field instead. --- Makefile | 2 - SCHEMA_DELTAS.md | 135 +- docs/design/type-generation.md | 357 +- docs/extending-types.md | 107 +- docs/reporting-production.md | 2 +- docs/reporting-receipt-ingress.md | 2 +- docs/reporting-release-notes.md | 2 +- docs/schema-modifiers-and-class-checks.md | 488 ++ docs/type-surface.md | 65 +- scripts/branch_classes.py | 925 +++ scripts/compile_conditions.py | 591 ++ scripts/consolidate_exports.py | 76 +- scripts/export_resolution.py | 2 +- scripts/generate_ergonomic_coercion.py | 726 --- scripts/generate_types.py | 241 +- scripts/generated_names.py | 67 + scripts/post_generate_fixes.py | 2371 +++++-- scripts/post_generation_manifest.json | 10 +- scripts/schema_constraints.py | 1924 ++++++ scripts/task_message_lattice.py | 44 +- scripts/union_branch_names.py | 467 ++ src/adcp/__main__.py | 15 +- src/adcp/canonical_formats/compat_helpers.py | 50 +- src/adcp/canonical_formats/format_options.py | 4 +- src/adcp/canonical_formats/identity.py | 36 + src/adcp/canonical_formats/projection.py | 13 +- src/adcp/canonical_formats/v1_to_v2.py | 3 +- src/adcp/canonical_formats/v2_to_v1.py | 3 +- src/adcp/client.py | 3 +- src/adcp/decisioning/_get_products_helpers.py | 38 +- src/adcp/decisioning/accounts.py | 2 - src/adcp/decisioning/compose.py | 6 - src/adcp/decisioning/derive_packages.py | 2 - src/adcp/decisioning/discovery_guards.py | 4 - src/adcp/decisioning/dispatch.py | 28 +- src/adcp/decisioning/handler.py | 12 - src/adcp/decisioning/negotiation.py | 6 - src/adcp/decisioning/pagination.py | 10 +- src/adcp/decisioning/pg/proposal_store.py | 2 - src/adcp/decisioning/platform.py | 2 - src/adcp/decisioning/platform_router.py | 5 - src/adcp/decisioning/property_list.py | 3 - src/adcp/decisioning/proposal_dispatch.py | 3 - src/adcp/decisioning/proposal_lifecycle.py | 2 - src/adcp/decisioning/proposal_manager.py | 2 - src/adcp/decisioning/proposal_store.py | 2 - src/adcp/decisioning/refine.py | 11 + src/adcp/decisioning/registry.py | 1 - src/adcp/decisioning/registry_cache.py | 1 - src/adcp/decisioning/resolve.py | 2 +- src/adcp/decisioning/state.py | 2 +- src/adcp/decisioning/state_machines.py | 3 - src/adcp/decisioning/time_budget.py | 9 + src/adcp/decisioning/trust_grants.py | 11 +- src/adcp/decisioning/types.py | 52 +- src/adcp/decisioning/upstream.py | 2 - src/adcp/governance.py | 201 +- src/adcp/server/auth.py | 1 - src/adcp/server/base.py | 90 +- src/adcp/server/content_standards.py | 45 +- src/adcp/server/governance.py | 63 +- src/adcp/server/idempotency/store.py | 1 - src/adcp/server/mcp_tools.py | 164 +- src/adcp/server/principal.py | 46 +- src/adcp/server/signed_requests.py | 11 +- src/adcp/server/sponsored_intelligence.py | 27 +- src/adcp/signing/_resolver_protocol.py | 58 +- src/adcp/signing/agent_resolver.py | 15 +- src/adcp/signing/trust.py | 2 - src/adcp/types/__init__.py | 13 +- src/adcp/types/_branch_dispatch.py | 249 + src/adcp/types/_conditions.py | 866 +++ src/adcp/types/_eager.py | 60 +- src/adcp/types/_ergonomic.py | 701 --- src/adcp/types/_forward_compat.py | 570 +- src/adcp/types/_generated.py | 134 +- src/adcp/types/_readback.py | 98 + src/adcp/types/_split_root.py | 73 + src/adcp/types/_targeting_compat.py | 170 + src/adcp/types/aliases.py | 205 +- src/adcp/types/canonical_creative.py | 351 +- src/adcp/types/capabilities.py | 14 +- src/adcp/types/coercion.py | 11 + src/adcp/types/domains/a2ui/__init__.py | 150 +- src/adcp/types/domains/aao/__init__.py | 63 +- src/adcp/types/domains/account/__init__.py | 194 +- .../account/list_account_changes_response.py | 178 +- .../account/sync_governance_response.py | 29 +- src/adcp/types/domains/brand/__init__.py | 370 +- .../domains/brand/search_brands_response.py | 203 +- .../brand/verify_brand_claim_request.py | 243 +- .../brand/verify_brand_claims_response.py | 2 +- .../get_adcp_capabilities_response.py | 190 +- src/adcp/types/domains/collection/__init__.py | 169 +- src/adcp/types/domains/compliance/__init__.py | 166 +- .../comply_test_controller_request.py | 262 +- .../comply_test_controller_response.py | 31 +- .../domains/content_standards/__init__.py | 279 +- .../create_content_standards_request.py | 43 +- .../create_content_standards_response.py | 29 +- .../list_content_standards_response.py | 29 +- .../update_content_standards_response.py | 29 +- src/adcp/types/domains/core/__init__.py | 5594 ++++++++++++----- src/adcp/types/domains/core/account.py | 36 +- .../core/account_timezone_capability.py | 52 +- .../domains/core/agent_notification_config.py | 28 +- .../core/agent_reporting_destination_state.py | 46 +- .../core/assets/daast_tracker_asset.py | 28 +- .../core/assets/pixel_tracker_asset.py | 28 +- .../core/assets/published_post_asset.py | 43 +- .../domains/core/assets/vast_tracker_asset.py | 28 +- ...ative_features_async_response_submitted.py | 28 +- .../get_products_async_response_submitted.py | 28 +- .../get_signals_async_response_submitted.py | 28 +- .../domains/core/attestation_capabilities.py | 28 +- .../domains/core/attestation_evaluation.py | 38 +- .../domains/core/attestation_reference.py | 51 +- .../domains/core/audience_characteristic.py | 200 +- .../types/domains/core/audience_evidence.py | 44 +- .../core/audience_evidence_requirements.py | 28 +- .../types/domains/core/audience_member.py | 47 +- src/adcp/types/domains/core/bidding_policy.py | 35 +- .../domains/core/bidding_policy_capability.py | 27 +- src/adcp/types/domains/core/budget_range.py | 43 +- .../core/canonical_audience_evidence.py | 46 +- .../domains/core/canonical_format_option.py | 44 +- .../domains/core/canonical_pricing_option.py | 60 +- .../types/domains/core/canonical_product.py | 54 +- .../types/domains/core/canonical_proposal.py | 52 +- .../types/domains/core/canvas_constraint.py | 28 +- .../domains/core/catalog_field_mapping.py | 36 +- .../core/catalog_item_availability_state.py | 44 +- .../core/catalog_item_availability_update.py | 44 +- ...catalog_item_availability_update_result.py | 68 +- src/adcp/types/domains/core/catchment.py | 208 +- .../domains/core/collection_distribution.py | 43 +- .../domains/core/creative_approval_scope.py | 36 +- src/adcp/types/domains/core/creative_asset.py | 213 +- .../types/domains/core/creative_assignment.py | 44 +- .../types/domains/core/creative_manifest.py | 246 +- .../creative_operation_format_declaration.py | 68 +- .../domains/core/creative_representation.py | 35 +- .../types/domains/core/creative_variant.py | 12 +- .../domains/core/daast_tracker_constraints.py | 46 +- .../types/domains/core/deadline_policy.py | 27 +- .../types/domains/core/delivery_metrics.py | 28 +- .../types/domains/core/delivery_recipient.py | 36 +- .../domains/core/demographic_age_range.py | 43 +- .../core/demographic_reporting_capability.py | 43 +- .../core/demographic_targeting_resolution.py | 54 +- .../core/downstream_connection_requirement.py | 28 +- src/adcp/types/domains/core/error.py | 28 +- src/adcp/types/domains/core/event.py | 28 +- .../domains/core/forecast_dimension_geo.py | 52 +- .../domains/core/forecast_dimension_signal.py | 59 +- src/adcp/types/domains/core/forecast_range.py | 43 +- src/adcp/types/domains/core/format.py | 236 +- src/adcp/types/domains/core/format_id.py | 45 +- src/adcp/types/domains/core/frequency_cap.py | 72 +- .../domains/core/frequency_cap_constraints.py | 59 +- .../frequency_cap_impression_constraints.py | 175 +- .../frequency_cap_interval_constraints.py | 211 +- src/adcp/types/domains/core/geo_place_area.py | 30 +- .../core/get_geo_place_resolution_request.py | 172 +- .../core/get_geo_place_resolution_response.py | 28 +- .../types/domains/core/indicator_bearing.py | 120 +- .../core/indicators_changed_webhook.py | 36 +- .../domains/core/installment_deadlines.py | 27 +- .../types/domains/core/macro_declaration.py | 140 +- src/adcp/types/domains/core/macro_encoding.py | 44 +- .../core/macro_resolution_capability.py | 92 +- .../domains/core/macro_resolution_result.py | 124 +- .../domains/core/macro_translation_target.py | 76 +- .../types/domains/core/mcp_webhook_payload.py | 28 +- src/adcp/types/domains/core/media_buy.py | 46 +- .../types/domains/core/media_buy_features.py | 8 +- .../media_buy_frequency_cap_capability.py | 28 +- .../media_buy_frequency_cap_requirement.py | 28 +- .../core/media_buy_frequency_cap_support.py | 28 +- .../types/domains/core/media_buy_support.py | 44 +- .../core/media_buy_support_requirements.py | 36 +- .../types/domains/core/notification_config.py | 28 +- .../types/domains/core/opportunity_context.py | 36 +- src/adcp/types/domains/core/package.py | 52 +- .../core/package_delivery_metric_value.py | 28 +- .../core/performance_feedback_assertion.py | 36 +- src/adcp/types/domains/core/placement.py | 67 +- .../domains/core/placement_definition.py | 51 +- .../types/domains/core/planned_delivery.py | 36 +- .../core/positive_postal_area_support.py | 27 +- .../types/domains/core/postal_area_support.py | 27 +- .../types/domains/core/principal_state.py | 29 +- src/adcp/types/domains/core/product.py | 133 +- .../product_audience_evidence_requirements.py | 28 +- .../core/product_card_reference_asset.py | 28 +- .../domains/core/product_offer_filters.py | 30 +- .../core/product_signal_targeting_option.py | 28 +- src/adcp/types/domains/core/proposal.py | 36 +- .../types/domains/core/protocol_envelope.py | 28 +- .../core/reporting_adjustment_receipt.py | 28 +- .../domains/core/reporting_consumer_status.py | 68 +- .../core/reporting_delivery_capabilities.py | 84 +- .../domains/core/reporting_delivery_config.py | 44 +- .../core/reporting_delivery_config_state.py | 44 +- .../core/reporting_delivery_offering.py | 36 +- .../core/reporting_ledger_changed_webhook.py | 28 +- .../domains/core/reporting_materialization.py | 68 +- .../domains/core/reporting_obligation.py | 100 +- .../types/domains/core/reporting_receipt.py | 54 +- .../core/reporting_reliability_statistics.py | 28 +- .../core/reporting_report_definition.py | 28 +- .../types/domains/core/reporting_resource.py | 38 +- .../types/domains/core/reporting_revision.py | 36 +- .../types/domains/core/reporting_schedule.py | 28 +- .../core/reporting_schedule_offering.py | 28 +- .../core/reporting_status_changed_webhook.py | 30 +- .../domains/core/reporting_status_issue.py | 28 +- .../domains/core/reporting_verification.py | 44 +- .../types/domains/core/reporting_webhook.py | 11 +- .../requirements/daast_asset_requirements.py | 28 +- .../requirements/image_asset_requirements.py | 30 +- .../requirements/vast_asset_requirements.py | 28 +- .../types/domains/core/rights_constraint.py | 30 +- .../domains/core/signal_coverage_forecast.py | 30 +- .../types/domains/core/signal_definition.py | 70 +- .../core/signal_definition_enrichment.py | 30 +- src/adcp/types/domains/core/signal_listing.py | 59 +- .../core/signal_modeling_disclosure.py | 28 +- .../core/targeting_overlay_requirements.py | 35 +- .../domains/core/targeting_overlay_support.py | 37 +- .../types/domains/core/tasks_get_response.py | 28 +- src/adcp/types/domains/core/transformer.py | 45 +- .../types/domains/core/transformer_param.py | 44 +- src/adcp/types/domains/core/user_match.py | 55 +- .../domains/core/vast_tracker_constraints.py | 110 +- src/adcp/types/domains/core/warning.py | 36 +- .../types/domains/core/warning_resource.py | 52 +- .../core/webhook_challenge_response.py | 172 +- .../domains/core/wholesale_feed_webhook.py | 116 +- src/adcp/types/domains/creative/__init__.py | 471 +- .../creative/get_creative_delivery_request.py | 43 +- .../get_creative_delivery_response.py | 8 +- ...ative_features_async_response_submitted.py | 28 +- .../get_creative_features_terminal_success.py | 28 +- .../creative/list_creative_formats_request.py | 52 +- .../creative/list_creatives_request.py | 36 +- .../creative/list_transformers_request.py | 30 +- .../creative/preview_creative_request.py | 68 +- .../creative/preview_creative_response.py | 6 +- .../creative/sync_creatives_request.py | 73 +- .../domains/creative/validate_input_result.py | 36 +- src/adcp/types/domains/enums/__init__.py | 681 +- .../types/domains/error_details/__init__.py | 264 +- src/adcp/types/domains/extensions/__init__.py | 48 +- src/adcp/types/domains/formats/__init__.py | 271 +- .../types/domains/formats/canonical/_base.py | 30 +- .../domains/formats/canonical/audio_daast.py | 31 +- .../domains/formats/canonical/audio_vast.py | 31 +- .../domains/formats/canonical/display_tag.py | 28 +- .../types/domains/formats/canonical/html5.py | 28 +- .../types/domains/formats/canonical/image.py | 38 +- .../formats/canonical/native_in_feed.py | 36 +- .../formats/canonical/sponsored_placement.py | 28 +- .../domains/formats/canonical/video_hosted.py | 31 +- .../domains/formats/canonical/video_vast.py | 47 +- src/adcp/types/domains/governance/__init__.py | 372 +- .../governance/audience_constraints.py | 27 +- .../governance/check_governance_request.py | 149 +- .../governance/check_governance_response.py | 34 +- .../governance/get_plan_audit_logs_request.py | 47 +- .../report_plan_adjustment_request.py | 76 +- .../governance/report_plan_outcome_request.py | 44 +- .../governance/reported_outcome_error.py | 28 +- src/adcp/types/domains/media_buy/__init__.py | 1587 +++-- .../media_buy/accept_proposal_request.py | 35 +- .../domains/media_buy/acceptance_context.py | 29 +- .../media_buy/acceptance_policy_catalog.py | 43 +- .../media_buy/acceptance_policy_profile.py | 30 +- .../media_buy/acceptance_policy_rule.py | 30 +- .../media_buy/build_creative_request.py | 98 +- .../media_buy/build_creative_response.py | 39 +- .../domains/media_buy/buy_products_request.py | 61 +- .../media_buy/control_media_buy_request.py | 107 +- .../media_buy/create_media_buy_request.py | 279 +- .../get_media_buy_delivery_request.py | 36 +- .../get_media_buy_delivery_response.py | 36 +- .../get_products_async_response_submitted.py | 28 +- .../media_buy/get_products_rejected.py | 31 +- .../domains/media_buy/get_products_request.py | 45 +- .../media_buy/get_products_response.py | 46 +- .../media_buy/get_reporting_status_request.py | 46 +- .../get_reporting_status_response.py | 92 +- .../list_creative_formats_request.py | 26 +- .../media_buy/list_products_request.py | 45 +- .../types/domains/media_buy/outcome_target.py | 43 +- .../domains/media_buy/package_control.py | 139 +- .../domains/media_buy/package_request.py | 65 +- .../types/domains/media_buy/package_update.py | 46 +- .../media_buy/product_discovery_criteria.py | 29 +- .../domains/media_buy/product_purchase.py | 30 +- .../media_buy/product_purchase_input.py | 40 +- .../media_buy/proposal_budget_constraint.py | 43 +- .../domains/media_buy/proposal_decline.py | 28 +- .../media_buy/request_proposals_request.py | 173 +- .../media_buy/sync_audiences_request.py | 30 +- .../media_buy/sync_catalogs_request.py | 36 +- .../sync_reporting_receipts_request.py | 43 +- .../media_buy/update_media_buy_request.py | 36 +- .../types/domains/pricing_options/__init__.py | 99 +- .../pricing_options/revenue_share_option.py | 36 +- src/adcp/types/domains/property/__init__.py | 206 +- src/adcp/types/domains/protocol/__init__.py | 615 +- .../get_adcp_capabilities_response.py | 149 +- .../domains/protocol/get_principal_request.py | 28 +- .../protocol/get_principal_response.py | 34 +- .../protocol/get_task_status_response.py | 28 +- .../protocol/sync_principal_request.py | 28 +- .../protocol/sync_principal_response.py | 34 +- src/adcp/types/domains/registries/__init__.py | 78 +- src/adcp/types/domains/signals/__init__.py | 167 +- .../get_signals_async_response_submitted.py | 28 +- .../domains/signals/get_signals_request.py | 48 +- .../domains/signals/get_signals_response.py | 30 +- .../sponsored_intelligence/__init__.py | 259 +- .../si_send_message_request.py | 43 +- .../si_sponsored_context_receipt.py | 52 +- .../sponsored_intelligence/si_ui_element.py | 84 +- .../types/domains/trusted_match/__init__.py | 195 +- .../trusted_match/context_match_response.py | 34 +- .../trusted_match/identity_match_response.py | 26 +- src/adcp/types/domains/trusted_match/offer.py | 38 +- .../provider_context_match_response.py | 34 +- .../provider_identity_match_response.py | 28 +- src/adcp/types/error_narrowing.py | 201 +- src/adcp/types/legacy.py | 24 +- src/adcp/types/projections.py | 15 +- src/adcp/types/response_dispatch.py | 135 - src/adcp/utils/preview_cache.py | 29 +- src/adcp/validation/pydantic_errors.py | 281 + src/adcp/validation/schema_errors.py | 70 +- src/adcp/validation/schema_validator.py | 310 +- tests/capture_root_documents.py | 414 ++ tests/conformance/_schema_parity.py | 8 +- .../reporting/_production_support.py | 5 + .../test_reporting_core_lifecycle.py | 21 +- .../reporting/test_reporting_currency.py | 64 +- .../test_reporting_notification_packaging.py | 63 +- .../test_reporting_reconciliation_store.py | 12 +- ...st_reporting_reconciliation_transitions.py | 3 + .../test_reporting_status_capabilities.py | 43 +- .../test_branch_constraint_inheritance.py | 445 ++ .../test_request_branch_requiredness.py | 160 + tests/fixtures/public_api_snapshot.json | 108 +- .../validation-model-divergences.json | 15 +- tests/fixtures/validation-parity-cases.json | 299 +- tests/test_account_lifecycle.py | 6 +- tests/test_account_projection.py | 3 - tests/test_agent_resolver.py | 143 +- tests/test_beta3_url_constraints.py | 11 +- tests/test_branch_classes.py | 691 ++ tests/test_branch_dispatch_on_every_path.py | 507 ++ ...bundled_schema_matches_generated_assets.py | 302 + .../test_canonical_boundary_serialization.py | 11 +- tests/test_canonical_creatives_rc3.py | 9 +- tests/test_canonical_formats_compatibility.py | 96 + ...t_canonical_formats_declaration_v1_refs.py | 100 + tests/test_canonical_models_are_subclasses.py | 193 +- tests/test_canonical_removed_fields.py | 87 +- tests/test_client.py | 16 +- tests/test_code_generation.py | 593 +- tests/test_codegen_contract_compatibility.py | 112 +- tests/test_codegen_invariants.py | 15 +- tests/test_composability_invariant.py | 11 +- tests/test_conditions_vocabulary.py | 763 +++ tests/test_decisioning_async_discovery.py | 90 +- .../test_decisioning_brand_authz_dispatch.py | 12 +- .../test_decisioning_buyer_agent_dispatch.py | 10 +- tests/test_decisioning_handler.py | 26 +- tests/test_decisioning_property_list.py | 14 +- tests/test_decisioning_refine.py | 6 +- tests/test_decisioning_serve.py | 7 +- tests/test_decisioning_types.py | 28 +- tests/test_decisioning_webhook_emit.py | 20 +- tests/test_decisioning_wire_dispatch.py | 24 +- tests/test_delivery_manifest_readback.py | 25 +- tests/test_dispatch_instance_identity.py | 236 + tests/test_domain_facades_are_lazy.py | 81 + tests/test_dropped_keyword_doors.py | 260 + tests/test_error_field_jsonpath_lite.py | 460 ++ tests/test_error_narrowing.py | 124 +- tests/test_error_recovery_table.py | 216 + tests/test_extra_policy.py | 76 +- tests/test_feed_mirror.py | 80 +- tests/test_forward_compat_assets.py | 132 +- tests/test_forward_compat_format_kind.py | 31 +- tests/test_generate_types_safety.py | 42 +- tests/test_generated_hierarchy.py | 113 +- tests/test_geo_place_system_keys.py | 4 +- tests/test_get_products_projection.py | 14 +- tests/test_governance_refusal_vocabulary.py | 195 + tests/test_implementation_config.py | 34 +- tests/test_import_layering.py | 9 +- tests/test_issue_keyword_vocabulary.py | 342 + tests/test_lazy_types.py | 110 +- ...test_legacy_surface_names_every_branch.py} | 56 +- tests/test_media_buy_store_integration.py | 9 +- tests/test_negotiated_catalog_purchase.py | 12 +- tests/test_no_import_time_field_patching.py | 111 + tests/test_observability.py | 4 +- tests/test_open_format_kind_vocabulary.py | 12 +- tests/test_preview_html.py | 14 +- tests/test_principal.py | 19 +- ..._principal_rejection_names_no_internals.py | 136 + tests/test_product_format_declaration.py | 4 +- tests/test_proposal_lifecycle_e2e.py | 102 +- tests/test_protocol_envelope_inheritance.py | 40 +- tests/test_rc6_adoption.py | 11 +- tests/test_reliable_reporting_service.py | 18 + tests/test_reporting_adjustment_evidence.py | 10 +- tests/test_reporting_capability_models.py | 116 +- tests/test_reporting_consumer_loop.py | 17 + tests/test_reporting_exact_request_models.py | 9 +- .../test_reporting_frozen_read_corrections.py | 37 +- tests/test_reporting_frozen_read_safety.py | 175 +- tests/test_reporting_interop_corpus.py | 45 +- tests/test_reporting_interop_matrix.py | 8 +- tests/test_reporting_reconciliation.py | 215 +- tests/test_reporting_revision_ownership.py | 23 +- tests/test_reporting_wire_defaults.py | 17 +- .../test_request_validation_no_input_echo.py | 218 + tests/test_root_conjunct_defaults.py | 538 ++ tests/test_root_constraint_emission.py | 147 + tests/test_root_document_capture.py | 132 + tests/test_schema_constraints.py | 727 +++ tests/test_schema_partition.py | 334 + tests/test_schema_validation.py | 21 +- tests/test_server_framework.py | 26 +- tests/test_simple_api.py | 23 +- tests/test_targeting_overlay_compat.py | 76 +- tests/test_targeting_overlay_errors.py | 22 +- tests/test_type_check_fixtures.py | 145 + tests/test_type_coercion.py | 2 + tests/test_type_guards.py | 11 +- tests/test_typed_handler_params.py | 2 +- tests/test_union_branch_names.py | 434 ++ tests/test_validation_parity.py | 125 +- .../canonical_override_field_types.py | 16 +- tests/type_checks/extend_targeting_input.py | 44 +- 448 files changed, 42530 insertions(+), 8980 deletions(-) create mode 100644 docs/schema-modifiers-and-class-checks.md create mode 100644 scripts/branch_classes.py create mode 100644 scripts/compile_conditions.py delete mode 100644 scripts/generate_ergonomic_coercion.py create mode 100644 scripts/generated_names.py create mode 100644 scripts/schema_constraints.py create mode 100644 scripts/union_branch_names.py create mode 100644 src/adcp/types/_branch_dispatch.py create mode 100644 src/adcp/types/_conditions.py delete mode 100644 src/adcp/types/_ergonomic.py create mode 100644 src/adcp/types/_readback.py create mode 100644 src/adcp/types/_split_root.py create mode 100644 src/adcp/types/_targeting_compat.py delete mode 100644 src/adcp/types/response_dispatch.py create mode 100644 src/adcp/validation/pydantic_errors.py create mode 100644 tests/capture_root_documents.py create mode 100644 tests/conformance/test_branch_constraint_inheritance.py create mode 100644 tests/conformance/test_request_branch_requiredness.py create mode 100644 tests/test_branch_classes.py create mode 100644 tests/test_branch_dispatch_on_every_path.py create mode 100644 tests/test_bundled_schema_matches_generated_assets.py create mode 100644 tests/test_canonical_formats_declaration_v1_refs.py create mode 100644 tests/test_conditions_vocabulary.py create mode 100644 tests/test_dispatch_instance_identity.py create mode 100644 tests/test_domain_facades_are_lazy.py create mode 100644 tests/test_dropped_keyword_doors.py create mode 100644 tests/test_error_field_jsonpath_lite.py create mode 100644 tests/test_error_recovery_table.py create mode 100644 tests/test_governance_refusal_vocabulary.py create mode 100644 tests/test_issue_keyword_vocabulary.py rename tests/{test_legacy_surface_names_every_arm.py => test_legacy_surface_names_every_branch.py} (85%) create mode 100644 tests/test_no_import_time_field_patching.py create mode 100644 tests/test_principal_rejection_names_no_internals.py create mode 100644 tests/test_request_validation_no_input_echo.py create mode 100644 tests/test_root_conjunct_defaults.py create mode 100644 tests/test_root_constraint_emission.py create mode 100644 tests/test_root_document_capture.py create mode 100644 tests/test_schema_constraints.py create mode 100644 tests/test_schema_partition.py create mode 100644 tests/test_type_check_fixtures.py create mode 100644 tests/test_union_branch_names.py diff --git a/Makefile b/Makefile index 64bf08d2f..3b0b42de0 100644 --- a/Makefile +++ b/Makefile @@ -88,8 +88,6 @@ regenerate-schemas: ## Download latest schemas and skills from bundle, then rege $(PYTHON) scripts/generate_types.py @echo "Consolidating exports..." $(PYTHON) scripts/consolidate_exports.py - @echo "Generating ergonomic coercion..." - $(PYTHON) scripts/generate_ergonomic_coercion.py @echo "✓ Schemas regenerated successfully" validate-generated: ## Validate generated code (syntax, imports, and equality with a regeneration) diff --git a/SCHEMA_DELTAS.md b/SCHEMA_DELTAS.md index 5ee2bca03..17cb813dc 100644 --- a/SCHEMA_DELTAS.md +++ b/SCHEMA_DELTAS.md @@ -2,27 +2,120 @@ ## Field changes -- `account/list_account_changes_request.py` - - `ListAccountChangesRequest`: `-adcp_major_version`, `-adcp_version` -- `brand/search_brands_request.py` - - `SearchBrandsRequest`: `-adcp_major_version` -- `media_buy/accept_proposal_request.py` - - `AcceptProposalRequest`: `-adcp_major_version`, `-adcp_version` +- `account/list_account_changes_response.py` + - **classes added**: ListAccountChangesResponseCompleted, ListAccountChangesResponseFailed + - `ListAccountChangesResponse`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `account/sync_governance_response.py` + - **classes added**: SyncGovernanceResponse +- `brand/search_brands_response.py` + - **classes added**: SearchBrandsError, SearchBrandsSuccess + - `SearchBrandsResponse`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `brand/verify_brand_claim_request.py` + - **classes added**: VerifyBrandClaimRequestVerifyParentClaim, VerifyBrandClaimRequestVerifyPropertyClaim, VerifyBrandClaimRequestVerifySubsidiaryClaim, VerifyBrandClaimRequestVerifyTrademarkClaim + - `VerifyBrandClaimRequest`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `bundled/protocol/get_adcp_capabilities_response.py` + - `Features1`: `+canonical_creatives` +- `compliance/comply_test_controller_response.py` + - **classes added**: ComplyTestControllerResponse +- `content_standards/create_content_standards_request.py` + - `CreateContentStandardsRequest`: `+_COMBINATOR_FIELDS` +- `content_standards/create_content_standards_response.py` + - **classes added**: CreateContentStandardsResponse +- `content_standards/list_content_standards_response.py` + - **classes added**: ListContentStandardsResponse +- `content_standards/update_content_standards_response.py` + - **classes added**: UpdateContentStandardsResponse +- `core/assets/published_post_asset.py` + - `PublishedPostAsset`: `+_COMBINATOR_FIELDS` +- `core/attestation_reference.py` + - `AttestationReference`: `+_COMBINATOR_FIELDS` +- `core/audience_characteristic.py` + - **classes added**: AudienceCharacteristicRange, AudienceCharacteristicValue + - `AudienceCharacteristic`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `core/audience_member.py` + - `AudienceMember`: `+_COMBINATOR_FIELDS` +- `core/budget_range.py` + - `BudgetRange`: `+_COMBINATOR_FIELDS` +- `core/catchment.py` + - **classes added**: CatchmentGeometry, CatchmentRadius, CatchmentTransportModeTravelTime + - `Catchment`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `core/collection_distribution.py` + - `CollectionDistribution`: `+_COMBINATOR_FIELDS` +- `core/creative_asset.py` + - **classes added**: CreativeAssetV1CreativeNamedFormatReference, CreativeAssetV2CreativeCanonicalFormatKind + - `CreativeAsset`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `core/creative_manifest.py` + - **classes added**: CreativeManifestCanonicalFormatManifest, CreativeManifestNamedFormatManifest + - `CreativeManifest`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `core/demographic_age_range.py` + - `DemographicAgeRange`: `+_COMBINATOR_FIELDS` +- `core/demographic_reporting_capability.py` + - `DemographicReportingCapability`: `+_COMBINATOR_FIELDS` +- `core/forecast_dimension_signal.py` + - `SignalForecastDimension`: `+_COMBINATOR_FIELDS` +- `core/forecast_range.py` + - `ForecastRange`: `+_COMBINATOR_FIELDS` +- `core/format.py` + - **classes added**: UnknownFormatAsset, UnknownGroupAsset +- `core/frequency_cap.py` + - `FrequencyCap`: `+_COMBINATOR_FIELDS` +- `core/frequency_cap_constraints.py` + - `FrequencyCapConstraints`: `+_COMBINATOR_FIELDS` +- `core/frequency_cap_impression_constraints.py` + - **classes added**: FrequencyCapImpressionConstraintsAllowedValues, FrequencyCapImpressionConstraintsMaximumMinimum + - `FrequencyCapImpressionConstraints`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `core/frequency_cap_interval_constraints.py` + - **classes added**: FrequencyCapIntervalConstraintsAllowedIntervals, FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval + - `FrequencyCapIntervalConstraints`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `core/get_geo_place_resolution_request.py` + - **classes added**: GetGeographicPlaceResolutionRequestQ, GetGeographicPlaceResolutionRequestValue + - `GetGeographicPlaceResolutionRequest`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `core/media_buy_features.py` + - `MediaBuyFeatures`: `+canonical_creatives` +- `core/placement.py` + - `Placement`: `+_COMBINATOR_FIELDS` +- `core/placement_definition.py` + - `PlacementDefinition`: `+_COMBINATOR_FIELDS` +- `core/product.py` + - `Product`: `+_COMBINATOR_FIELDS` +- `core/reporting_webhook.py` + - `ReportingWebhook`: `+operation_id` +- `core/signal_listing.py` + - `SignalListing`: `+_COMBINATOR_FIELDS` +- `core/transformer.py` + - `Transformer`: `+_COMBINATOR_FIELDS` +- `core/user_match.py` + - `UserMatch`: `+_COMBINATOR_FIELDS` +- `core/webhook_challenge_response.py` + - **classes added**: WebhookChallengeResponseChallenge, WebhookChallengeResponseToken + - `WebhookChallengeResponse`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `creative/get_creative_delivery_request.py` + - `GetCreativeDeliveryRequest`: `+_COMBINATOR_FIELDS` +- `creative/sync_creatives_request.py` + - `SyncCreativesRequest`: `+_COMBINATOR_FIELDS` +- `governance/get_plan_audit_logs_request.py` + - `GetPlanAuditLogsRequest`: `+_COMBINATOR_FIELDS` +- `media_buy/acceptance_policy_catalog.py` + - `AcceptancePolicyCatalog`: `+_COMBINATOR_FIELDS` - `media_buy/buy_products_request.py` - - `BuyProductsRequest`: `-adcp_major_version`, `-adcp_version` + - `BuyProductsRequest`: `+_COMBINATOR_FIELDS` - `media_buy/control_media_buy_request.py` - - `ControlMediaBuyRequest`: `-adcp_major_version`, `-adcp_version` -- `media_buy/decline_proposals_request.py` - - `DeclineProposalsRequest`: `-adcp_major_version`, `-adcp_version` -- `media_buy/list_products_request.py` - - `ListProductsRequest`: `-adcp_major_version`, `-adcp_version` -- `media_buy/refine_proposals_request.py` - - `RefineProposalsRequest`: `-adcp_major_version`, `-adcp_version` + - `ControlMediaBuyRequest`: `+_COMBINATOR_FIELDS` +- `media_buy/create_media_buy_request.py` + - **classes added**: CreateMediaBuyRequestCommittedProposalExecution, CreateMediaBuyRequestExplicitPackagesWithFixedAllocation, CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation + - `CreateMediaBuyRequest`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `media_buy/get_reporting_status_response.py` + - `GetReportingStatusResponse`: `+_COMBINATOR_FIELDS` +- `media_buy/outcome_target.py` + - `OutcomeTarget`: `+_COMBINATOR_FIELDS` +- `media_buy/package_control.py` + - `PackageControl`: `+_COMBINATOR_FIELDS` +- `media_buy/proposal_budget_constraint.py` + - `ProposalBudgetConstraint`: `+_COMBINATOR_FIELDS` - `media_buy/request_proposals_request.py` - - `RequestProposalsRequest`: `-adcp_major_version`, `-adcp_version` -- `media_buy/sync_reporting_status_request.py` - - `SyncReportingStatusRequest`: `-adcp_major_version`, `-adcp_version` -- `trusted_match/context_match_request.py` - - `ContextMatchRequest`: `-adcp_major_version`, `-adcp_version` -- `trusted_match/identity_match_request.py` - - `IdentityMatchRequest`: `-adcp_major_version`, `-adcp_version` + - **classes added**: RequestProposalsRequestAccount, RequestProposalsRequestBrand + - `RequestProposalsRequest`: `+_COMBINATOR_FIELDS`, `+_ROOT_REQUIRED` +- `media_buy/sync_reporting_receipts_request.py` + - `SyncReportingReceiptsRequest`: `+_COMBINATOR_FIELDS` +- `sponsored_intelligence/si_send_message_request.py` + - `SiSendMessageRequest`: `+_COMBINATOR_FIELDS` diff --git a/docs/design/type-generation.md b/docs/design/type-generation.md index bfceedf49..c2f7ce0a9 100644 --- a/docs/design/type-generation.md +++ b/docs/design/type-generation.md @@ -1,10 +1,11 @@ # How the type tree is generated Status: current. Generator pinned at `datamodel-code-generator==0.64.0`. -Schema pin: `src/adcp/ADCP_VERSION` = `3.2.1`, which `resolve_bundle_key` collapses to the +Schema pin: `src/adcp/ADCP_VERSION` = `3.2.3`, which `resolve_bundle_key` collapses to the bundle `schemas/cache/3.2`. -This document is for whoever has to change the pipeline. It covers every step in order, how +This document is for whoever has to change the pipeline. It covers every step in order, +what a schema root states that a Python class cannot express and how that is answered, how a class gets its name, what the guards are, how to add a schema feature, and what to do when the generator renders something wrong. @@ -13,8 +14,9 @@ For subclassing a generated type, see [../extending-types.md](../extending-types ## The shape of the thing -1053 non-bundled schema files become 1056 generated model modules, which become one flat -namespace of 923 names and a 1084-module public mirror under `adcp.types.domains`. Nothing +1057 non-bundled schema files become 1088 generated model modules, which become one flat +namespace of 3022 names under `adcp.types`, mirrored module-by-module under +`adcp.types.domains`. Nothing in it is hand-written: the flat `__all__`, the `_generated.py` arena, the mirror, the error-details exports and the collision table are all emitted. @@ -37,7 +39,6 @@ regenerate-schemas: ## Download latest schemas and skills from bundle, then rege $(PYTHON) scripts/generate_versioned_bases.py $(PYTHON) scripts/generate_types.py $(PYTHON) scripts/consolidate_exports.py - $(PYTHON) scripts/generate_ergonomic_coercion.py ``` | Step | Consumes | Produces | Network | @@ -47,9 +48,8 @@ regenerate-schemas: ## Download latest schemas and skills from bundle, then rege | `bundle_schemas.py` | the cache | `src/adcp/_schemas/**`, the schemas the wheel ships | no | | `generate_versioned_stubs.py` | the cache | `src/adcp/types/v30.pyi`, `v31.pyi`, `v32.pyi` | no | | `generate_versioned_bases.py` | the cache | `src/adcp/types/versioned_bases/**` | no | -| `generate_types.py` | the cache | `domains/**`, `_generated.py`, `_ergonomic.py` | no | +| `generate_types.py` | the cache | `domains/**`, `_generated.py` | no | | `consolidate_exports.py` | the generated tree | `_generated.py`, each `domains//__init__.py`, `domains/__init__.py`, `error_details.py`, `docs/shared-type-names.md` | no | -| `generate_ergonomic_coercion.py` | the generated tree | `_ergonomic.py` | no | `sync_schemas.py` is the only step that touches the network, and skipping it regenerates against the committed cache. That is the normal inner loop: @@ -59,10 +59,18 @@ python scripts/generate_types.py # regenerate from the committed cach python scripts/generate_types.py --check # verify without writing anything ``` -Note that `generate_types.py` already runs `consolidate_exports.py` and -`generate_ergonomic_coercion.py` as subprocesses against its staging tree and installs -their output, so the Makefile target runs both of them a second time. Harmless, but do not -read the Makefile as the statement of what depends on what. +Note that `generate_types.py` already runs `consolidate_exports.py` as a subprocess +against its staging tree and installs its output, so the Makefile target runs it a second +time. Harmless, but do not read the Makefile as the statement of what depends on what. + +`generate_ergonomic_coercion.py` used to be a third step here, producing +`src/adcp/types/_ergonomic.py`, which patched 74 field annotations at import time. Both are +deleted. 70 of those patches were inert — `model_rebuild` rebuilds a schema from +`model_fields`, not from `__annotations__` — and the coercion they appeared to supply is +native: a generated enum is a `StrEnum`, so a string is accepted without help, and pydantic +builds a model field from a dict without help. The four that were live are declared on the +generated field by `COMPATIBILITY_ANNOTATIONS` in `post_generate_fixes.py` +(salesagent-1jj5e). ### One tree @@ -102,13 +110,13 @@ In order: 7. `apply_post_generation_fixes` — a subprocess running `post_generate_fixes.py` against the staged tree. 8. `prune_unused_bundled_modules` — keep only the three paths in `BUNDLED_KEEP`. -9. Run `consolidate_exports.py`, then `generate_ergonomic_coercion.py`, against the staging - tree. The first writes the domain roots into the tree generated in step 4. +9. Run `consolidate_exports.py` against the staging tree, which writes the domain roots + into the tree generated in step 4. 10. `restore_unchanged_files` — restore byte-for-byte any file whose only change is a timestamp header, so a no-op regeneration produces no diff. Once, over the whole tree, after the roots are in it. 11. Compare the artifacts against the checkout — `domains/`, `_generated.py`, - `_ergonomic.py`, `error_details.py`, `docs/shared-type-names.md`. Under `--check`, + `error_details.py`, `docs/shared-type-names.md`. Under `--check`, print the drift and return 1 without writing. Otherwise install and write `SCHEMA_DELTAS.md`. @@ -135,6 +143,12 @@ compose; and it must follow `inline_structural_pointer_refs`, so the envelope re appends is never itself inlined back into copied fields — which would turn inheritance into flattened fields. +`flatten_root_object_validation_union` and `flatten_validation_oneof` delete a root's +combinator, which is what keeps the root's public name a subclassable class rather than a +`RootModel` union. What then enforces the combination is +*[What a schema root says that a class cannot](#what-a-schema-root-says-that-a-class-cannot)* +below; the two halves only make sense together. + Each of these operates on the staging copy only. The cached bundle, the schemas the wheel ships and any direct-from-schema validation are untouched, which is what keeps the transforms from changing what the SDK validates against. @@ -156,8 +170,9 @@ on the file being present and absent. ## The post-generation pass -`scripts/post_generate_fixes.py` is 7367 lines holding 130 module-level functions: 72 fix -entrypoints, built as one ordered list in `main()` and run in list order, plus 58 helpers. +`scripts/post_generate_fixes.py` is 7954 lines holding 153 module-level functions: 80 fix +entrypoints, built as one ordered list in `main()` and run in list order, plus the helpers +they share. Three ordering constraints are commented in the list itself and must be preserved: @@ -182,10 +197,218 @@ no syntax error and no test failure. Locate by AST, or through the shared preserve the statement's form, and raise rather than mangle. Ten fixes still match an import by exact literal text; that is recorded fragility, not a pattern to copy. -There is no per-fix effect ledger today, so a fix whose locator goes stale stops working -silently until a test notices. PR #1374 adds one — a declared manifest of which fixes -change files, re-measured rather than hand-edited, checked on every run — and that is the -mechanism to extend rather than inventing a second one. +`scripts/post_generation_manifest.json` is the per-fix effect ledger: it declares which +fixes change files, it is re-measured rather than hand-edited, and a fix the manifest +records as firing that changes nothing fails the run. Extend it rather than inventing a +second mechanism — a renamed identifier inside a fix's locator string is exactly what it +catches, because the fix then searches for a class the generator never writes. + +## What a schema root says that a class cannot + +A schema root says more than "here are my properties". It can say *one of these +combinations of fields* (`anyOf`, `oneOf`), or *if this field, then that one* (`if`/`then`, +usually inside a root `allOf`), or *never these two together* (`not`), or +*this field only alongside that one* (`dependencies`). + +**A Python class is a flat field set with per-field requiredness, and that cannot express +"these three together, or that one alone."** `create-media-buy-request` declares three ways +to be a media buy — explicit packages with a fixed allocation, explicit packages with a +seller-optimised one, or the execution of a committed proposal — and the first two require +`packages` while the third forbids it. One class with `packages` optional accepts a request +that is none of the three. + +Three consequences follow, and the pipeline has an answer for each. + +`datamodel-code-generator` renders a root `anyOf`/`oneOf` as a `RootModel` union. That +union *does* enforce the combination, and it is not subclassable or constructible — so the +root's public name stops being a type a consumer can extend, annotate against, or hold. + +The pre-generation transforms `flatten_root_object_validation_union` and +`flatten_validation_oneof` therefore delete the root combinator before the generator sees +it, which is what keeps the public name a class. They say so in their own docstrings. + +A root `if`/`then` inside an `allOf` was a third case, and the quietest: the admission +predicate that decides which roots carry constraint content read only the root's own keys, +and this bundle puts conditional rules inside `allOf` MEMBERS. It descends into them now — +`core/reporting-obligation` has ten such members, one of which requires +`pending_adjustment_count`. + +## Branches that partition become types; rules that do not partition stay checks + +That is the whole rule, and which side a root falls on is proved rather than chosen. + +| the root's branches | what is generated | population | +|---|---|---| +| partition by requiredness | one class per branch, a shared public base, the base dispatches | **19 declaring classes, 49 branch classes** | +| do not partition | a compiled check on the class | **215 generated modules carry one** | + +Nineteen classes declare `_branch_models`: the split roots, the five public response bases +described below, and the canonical create request. 43 of the 215 compiled checks refuse with +a combinator keyword; the rest state a conjunct. + +Measured at `3488ff9c3` over 1083 generated modules. Count with +`git grep -l -- src/adcp/types/domains/` rather than `grep -r` over a +working tree: `grep -r` matches `__pycache__/*.pyc` too and returns exactly double. The +earlier figures here (13 roots, 30 branch classes, 203 modules) were correct when written +and are not re-derived on a refresh, so treat any number in this file as "last measured at" +rather than current. + +**What the emitted checks DO at validation time** — what a refusal reports, which branch's +errors a caller sees, how a conditional binds, and what is not checked at all — is +[`../schema-modifiers-and-class-checks.md`](../schema-modifiers-and-class-checks.md). + +`scripts/schema_constraints.py` is the one reader. It parses a root's constraint content +into `RootConstraints` — a combinator, its `Branch` records, and the conjuncts beside it — +and `partitions_by_requiredness` decides the row. Partitioning means the branches' +required-field sets are pairwise disjoint in a way a document cannot satisfy two of, which +`provably_disjoint` establishes from the branches themselves. + +### Parsing is key-consuming + +For every node it visits, the reader accounts for every key as exactly one of: **compiled** +into a `Cond`; **non-asserting** by a named frozen set (`title`, `$comment`, `default` and +the rest, each listed with the reason it carries no validation semantics); **declared +behavioral**, which is exactly `x-adcp-validation`; or a **proven restatement** of what the +generated class already declares, where equality is checked rather than assumed. + +Anything else raises `UnexpressibleKeywordError` and aborts generation, naming the file, the +JSON pointer and the keyword. There is no list of known-dropped keywords in the module, no +skip lane and no allowlist: a keyword is compiled, classified by a named rule, or fatal. +That is what makes "the model is weaker than its schema, silently" unconstructible for a +root the reader examines rather than merely detected. + +### A partitioning root: one class per branch + +`scripts/branch_classes.py` renders a base carrying the root's own properties, one subclass +per branch restating only what that branch changes, and a union alias over them: + +```python +class WebhookChallengeResponse(BranchDispatchMixin, AdCPBaseModel): + """What every branch of core/webhook-challenge-response.json shares.""" + challenge: str | None = None + token: str | None = None + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + return (WebhookChallengeResponseChallenge, WebhookChallengeResponseToken) + + @classmethod + def _branch_combinator(cls) -> str: + return 'anyOf' +``` + +What that makes **unspellable**, which is a different claim from refusing it: + +- a field a branch forbids is typed `None` on that branch, so + `CreateMediaBuyRequestCommittedProposalExecution(packages=[...])` is a type error before + anything runs. It is declared rather than omitted because the base declares it; omitting + it would inherit the base's permissive annotation. +- a branch's required field is required on that branch's class, so leaving it out is a + missing argument rather than a validator's refusal. + +Everything in a branch's condition that is **not** requiredness or absence stays a compiled +check on that branch's own class: a `const` one level down, an overlay on an array's items, +a value bound. The refusal then still arrives from the class the caller named. + +Branch class names come from the schema, through `scripts/union_branch_names.py`'s ordered +rungs over the same `Branch` records — the branch's `title` first, then a discriminator +`const`, then its full `required` set — failing closed rather than numbering. No identifier +is invented: `anyOf/0` titled "Explicit packages with fixed allocation" becomes +`CreateMediaBuyRequestExplicitPackagesWithFixedAllocation`, and that title also opens the +branch class's docstring, which is where a refusal reads it back. + +### The base dispatches, on every validation path + +`src/adcp/types/_branch_dispatch.py` holds one `BranchDispatchMixin` for the whole tree. It +gives the base the branches' core schema, so the root's public name resolves to the branch +a document satisfies — called directly, nested as another model's field, validated through +a `TypeAdapter`, or constructed. + +All four paths matter, and three of them are invisible to a `model_validate` override: +pydantic validates a nested field and a `TypeAdapter` through the core schema, and most +roots with branches are nested object schemas. `adcp.types.validation.validate_union`, the +public helper the server framework calls on a handler's return value, is a `TypeAdapter`. + +**Which class gets the branches' schema is a fact about the class, not a registration:** the +base is the class that DECLARES `_branch_models`. A branch inherits the method and gets its +own shape back; so does a refinement declared elsewhere, such as the canonical creative +layer's narrowing of a single branch. Nothing has to be registered, so nothing can be +forgotten — a new partitioning root works by emitting that method. + +Membership is not a sufficient test for it. `cls in branches` admits both "is a branch" and +"is an unrelated subclass of the base", and only the second matters once the schema itself +is being replaced. + +The branches subclass the base, so they do not exist while the base's body is compiled. The +hook returns the base's own schema then, and the declaring module's +`model_rebuild(force=True)` after the branches are defined installs the union — a forward +reference resolved in the module that declares it, which is what `model_rebuild` is for. + +### A refusal names a JSON Schema keyword + +`core/error.json` requires every `issues[]` entry to carry a `keyword` drawn from the JSON +Schema vocabulary, and tells a consumer not to invent one. A document satisfying no branch +fails each branch at FIELD validation, so pydantic reports `missing`, which is outside that +vocabulary — a consumer assembling a conformant envelope would have nothing valid to put in +the field. + +So the union carries a wrapping validator that supplies the root's own combinator, and the +sentence it writes is the one a flat root's compiled validator writes: + +``` +requires at least one of these field groups: Explicit packages with fixed allocation | +Explicit packages with seller-optimized allocation | Committed proposal execution +``` + +A root moving between the two mechanisms changes how it refuses, not what a reader sees. +The absent field names travel beside the titles in the error context, where a consumer +reads them without parsing the sentence. + +A compiled check runs `mode="after"`, so the value it grades is what pydantic PARSED rather +than what the document carried. The `type` predicate therefore accepts the Python types a +JSON string arrives as — `datetime`, `AnyUrl`, `UUID` and the rest — because +`isinstance(value, str)` is false for every `format`-bearing field. They are enumerated +rather than defined by excluding the other five JSON types: a JSON *number* also arrives as +a non-`float`, since pydantic parses one into `Decimal` where the field says so. + +### What this does not reach + +The admission predicate `root_carries_constraints` admits **533** roots across the bundle's +1625 schema files. **205** of those have a shape the reader can read, and get a compiled +check or a split. Of the rest, **114 are canonical-tree roots with a generated module that +get nothing**: `flat_root_shape` needs a flat class carrying the root's properties, and +these are the roots where the generator emitted numbered classes plus a union alias instead +of merging onto one class. + +`account/sync-accounts-response.json` is the shape — a root `oneOf` with two members, root +`properties` empty, `SyncAccountsResponse1` and `SyncAccountsResponse2` generated, and no +validator in the module. Its root `oneOf` and its two `allOf` conjuncts are unenforced. + +**92 of the 114 have exactly the shape the five public response bases have** (below), so the +mechanism that covers them is the one already here. This is a gap in the ADMISSION path +rather than a residue set inside the parser — a root that reaches the parser is still +compiled, classified or fatal — but it produces the same outcome the parser's contract +exists to prevent, so it is named here rather than left to turn up one root at a time. + +### The five public response bases + +Five response names predate the generator emitting a union, and callers construct them. For +those, the post-generation pass restores a constructible base over the numbered classes the +generator wrote and mixes in the same `BranchDispatchMixin`: + +```python +class CreateContentStandardsResponse(BranchDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response branches.""" +``` + +`ComplyTestControllerResponse`, `CreateContentStandardsResponse`, +`ListContentStandardsResponse`, `UpdateContentStandardsResponse` and +`SyncGovernanceResponse`. The combinator each names on refusal is read from its root's own +schema; all five declare `oneOf`. + +The list of five is hand-written, because "which names stay constructible" is a +compatibility decision rather than a property of the schemas. A row the generated tree no +longer answers raises rather than being stepped over. ## `consolidate_exports.py` @@ -265,20 +488,31 @@ with it two messages. ### Root class names: `title`-derived -The generator names a root class from the schema's `title`. The repo reproduces that rule -only to read it back: +The generator names a root class from the schema's `title`, and the repo ASKS it for that +name rather than restating the rule. `scripts/generated_names.py` owns both of the +generator's naming rules and nothing else: ```python -def mangle_schema_title(title: str) -> str: - return "".join(word.capitalize() for word in re.split(r"[^0-9a-zA-Z]+", title) if word) +generated_root_class_name(title) # a schema ROOT +generated_nested_class_name(title) # a named NESTED object ``` -Measured against all 154 task-message schemas at the pin, this reproduces the generated -root class or union-alias name for 154 of 154. Note `str.capitalize`, which lower-cases the -tail — that is the generator's behavior, and it is the part a filename-derived guess gets -wrong in both directions: `Get AdCP Capabilities Request` becomes -`GetAdcpCapabilitiesRequest`, and `list-creative-formats-request.json` does *not* become -`ListCreativeFormatsRequest` because its title names the agent variant. +There are two because the generator applies two: a root goes through +`_resolve_root_model_name`, which title-cases a title that is not already a Python +identifier and keeps one that is, so `Get AdCP Capabilities Request` becomes +`GetAdcpCapabilitiesRequest` while `GetCreativeFeaturesSuccess` is kept verbatim; a nested +object goes through `default_class_name_generator`, which PRESERVES an acronym, so +`VAST Asset` becomes `VASTAsset` and not `VastAsset`. Using either rule in the other's +position is wrong for every acronym. + +Use these two functions for any pass that needs a class name from a title, and do not +re-derive a third answer — a derived name that matches nothing is silently green, and a +consumer keying on one (a capture index, a name resolved before the branches exist) has no +module to resolve against. +`tests/test_generated_hierarchy.py::test_the_root_class_name_derived_from_a_title_is_the_name_the_module_declares` +grades the root derivation against the generated tree on every run, over the 946 titled +roots that declare a single class to compare against, with a population floor so a +derivation that stops resolving fails instead of comparing an empty list. Nothing in the pipeline injects a `title` to steer this. @@ -299,7 +533,7 @@ def _is_structural_pointer(fragment: str) -> bool: A definition-container pointer names a reusable subschema, and the generator emits one shared class for it. Every other pointer addresses a structural position — `/properties/`, `/items`, `/oneOf/` — which has no name, so the generator mints a -one-arm `RootModel` wrapper per referencing site. `inline_structural_pointer_refs` replaces +one-branch `RootModel` wrapper per referencing site. `inline_structural_pointer_refs` replaces such a reference with the subschema it selects, so the wrapper is never named at all. The predicate is on the *selected subschema*, not on the pointer token: inline only what @@ -310,32 +544,39 @@ declared as a pointer-or-null union, which otherwise produces `targeting.AudienceExclude = RootModel[list[str]]` and becomes `list[str] | None` once resolved. -### Union arm names: positional today +### Union branch names: schema-derived -Root-level union arms are named by position. `restore_response_variant_aliases` mints -them off a hand-written 22-row table of `(module path, base class name)` pairs: +A branch class is named from its own schema node, through +`scripts/union_branch_names.py`'s ordered rungs over the reader's `Branch` records: the +branch's `title`, then a discriminator `const`, then its full `required` set. A branch no +rung can name raises rather than taking a position, so a name is never a number. ```python -class_names = [f"{self.base}{index}" for index in range(1, len(arms) + 1)] +# core/webhook-challenge-response.json, anyOf/0 titled "Challenge" +WebhookChallengeResponseChallenge +# media-buy/create-media-buy-request.json, anyOf/2 titled "Committed proposal execution" +CreateMediaBuyRequestCommittedProposalExecution ``` -and carries a hardcoded permutation so that an upstream insertion did not move two existing -public names: +Exactly one branch in the pinned bundle is unnameable by every rung, and +`test_union_branch_names.py` names it. + +Response branches the generator numbers are a separate case. +`restore_response_variant_aliases` mints `1 … N` off a hand-written 22-row +table of `(module path, base class name)` pairs, and carries a hardcoded permutation so an +upstream insertion does not move two existing public names: ```python -if self.base == "BuildCreativeResponse" and len(arms) == 6: +if self.base == "BuildCreativeResponse" and len(branches) == 6: # Preserve the long-standing public numbering where Response1 is the simple - # success arm and Response2 is the error arm; append newer schema branches after those. - arms = [arms[index] for index in (0, 4, 1, 2, 3, 5)] + # success branch and Response2 is the error branch; append newer schema + # branches after those. + branches = [branches[index] for index in (0, 4, 1, 2, 3, 5)] ``` That permutation is the drift channel made visible: a positional name's meaning changed, -and a hand-written reorder was the only way to hold it still. - -A rule that names an arm from its schema node (`title`, then discriminator `const`, -then full `required` set, failing closed instead of numbering) is designed and measured -against the pinned bundle but not wired in; it is tracked, with the two surface-policy -decisions wiring it needs, in #1409. +and a hand-written reorder was the only way to hold it still. Extending the schema-derived +rule to those names needs two surface-policy decisions, tracked in #1409. ## What keeps a name's meaning stable, and where it does not @@ -346,8 +587,8 @@ The property wanted is: a name refers to the same schema node until the schema c follows filesystem insertion order. `test_flatten_schemas_uses_stable_path_order` grades it. 2. **Provenance is read, not guessed.** The generator's own `filename:` header, and - `mangle_schema_title` reproducing the generator's title rule for 154 of 154 task - messages, mean a pass that renames or marks a class finds it by what produced it. + `scripts/generated_names.py` asking the generator for a class name instead of restating + its rule, mean a pass that renames or marks a class finds it by what produced it. 3. **The staleness check makes instability visible.** `generate_types.py --check` re-runs the whole pipeline into a staging tree and compares all three artifacts byte-for-byte. Any input-order sensitivity shows up as a dirty regeneration. @@ -376,8 +617,8 @@ The one authority on whether the committed tree matches the pipeline is: python scripts/generate_types.py --check # "✓ Generated types are up to date" ``` -It stages a complete regeneration, compares `domains/`, `_generated.py` and -`_ergonomic.py` byte-for-byte against the checkout, and writes nothing. **Run it yourself +It stages a complete regeneration, compares `domains/` and `_generated.py` byte-for-byte +against the checkout, and writes nothing. **Run it yourself after any pipeline change: `make validate-generated` does not.** That target checks syntax and the two versioned-stub generators only; PR #1374 is what wires the staleness check into the Makefile and into CI as a blocking step. Until it lands, nothing automated fails on a @@ -457,12 +698,12 @@ exactly one schema declares it, it is there; if more than one does, it is in module to find the broken one. That is how the `from pydantic import (, model_validator` composition defect was located at all. 3. **Ask what the generator does with the whole root, not just the node you changed.** A - root `properties` is merged into every arm the generator builds, but a root `allOf` - `$ref` becomes a base only of the arm built *from the root object*: an arm that is a bare + root `properties` is merged into every branch the generator builds, but a root `allOf` + `$ref` becomes a base only of the branch built *from the root object*: an branch that is a bare `$ref` to another schema is generated from that schema, and the root's bases never reach it. Moving a field from `properties` into an `allOf` therefore **deletes** it from such an - arm rather than relocating it — measured on two schemas, where it removed `adcp_version` - from a submitted arm whose `extra` policy is `forbid`, so a seller echoing that field + branch rather than relocating it — measured on two schemas, where it removed `adcp_version` + from a submitted branch whose `extra` policy is `forbid`, so a seller echoing that field would then have been rejected. `_has_bare_ref_root_arm` is the refusal that resulted. 4. **Measure the whole field set, not the field you expected to change.** That deletion was found by comparing full `model_fields` sets before and after, in a step already reported @@ -496,13 +737,13 @@ churn and breaking generated-code imports that reference specific suffixes. $ref file not found: .../.schema_temp/media_buy/enums/proposal_status.json ``` -The trigger is a relative `$ref` inside a **multi-arm** `allOf`. +The trigger is a relative `$ref` inside a **multi-branch** `allOf`. `media-buy/media-buy-commitment-response.json` declares, at -`/oneOf/0/properties/accepted_proposal`, an `allOf` of two arms: a cross-directory `$ref` to +`/oneOf/0/properties/accepted_proposal`, an `allOf` of two branches: a cross-directory `$ref` to `core/canonical-proposal.json`, and an inline `const` overlay. The referenced document then refs `../enums/proposal-status.json`, and from 0.72.0 the generator joins that `../` against the *referring* document's path instead of the *defining* document's directory. -Isolated to the multi-arm form: a single-arm `allOf` and a plain `$ref` both generate +Isolated to the multi-branch form: a single-branch `allOf` and a plain `$ref` both generate cleanly. 19 releases were probed with a three-file minimal repro. 0.64.0 through 0.71.0 all return 0; @@ -556,7 +797,7 @@ constraint every layer above is built around. regenerations. Real source makes the stub unnecessary. - **Refused: growing a hand-maintained allowlist.** The collision allowlist's reader is gone; `KNOWN_COLLISIONS` is the one table left, and the derived guards above are what - replaced the rest. Nothing should add a third — including for arm naming, which fails + replaced the rest. Nothing should add a third — including for branch naming, which fails closed and takes a one-line upstream title change rather than a ledger of tolerated numbers. - **Refused: a positional name on the public surface.** Numbered classes may remain as diff --git a/docs/extending-types.md b/docs/extending-types.md index 11d019866..ef3aac577 100644 --- a/docs/extending-types.md +++ b/docs/extending-types.md @@ -13,15 +13,17 @@ 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 +## A Nested Object Union Is a Type Alias -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 union NESTED inside another schema — `AccountReference`, `PostalArea`, +`SignalRef`, `PricingOption` and the rest — is a public union alias, not a +Pydantic `RootModel` class. (A schema ROOT whose branches partition is a class; +see *A Split Root Is a Class* below.) +This keeps their object branches 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. +public wrapper around the constituent schema branches. -Construct a known arm directly. For example: +Construct a known branch directly. For example: ```python from adcp.types import AccountReferenceById @@ -29,7 +31,7 @@ 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 +For untrusted data where the branch is not known in advance, use Pydantic's `TypeAdapter`: ```python @@ -43,7 +45,90 @@ 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. +wrapper is removed, and validation returns the selected schema branch directly. + +## A Split Root Is a Class, and Its Branches Subclass It + +The section above is about a union *nested inside* another schema. A schema +**root** whose branches partition by requiredness is the other case, and its +public name is a class — so the guidance inverts. + +Thirteen roots are in that population, among them `CreateMediaBuyRequest`, +`CreativeAsset`, `CreativeManifest`, `WebhookChallengeResponse`, +`RequestProposalsRequest` and `ListAccountChangesResponse`. For each, the public +name is a base carrying the fields every branch shares, and one subclass per +branch carries that branch's own requiredness: + +```python +from adcp.types import CreateMediaBuyRequest, CreateMediaBuyRequestBranches + +# The public name validates, and returns the branch the document satisfies. +request = CreateMediaBuyRequest.model_validate(raw_request) +type(request) # CreateMediaBuyRequestCommittedProposalExecution + +# It is also a real base, so this holds for every branch: +isinstance(request, CreateMediaBuyRequest) # True +``` + +Three consequences for an adopter. + +**Validate through the public name.** `model_validate`, a field annotated with +it, and `TypeAdapter` all return the matching branch — the base carries the +branches' core schema, so every path agrees. A document satisfying no branch is +refused, and the refusal names the root's JSON Schema keyword with the branch +titles: + +``` +requires at least one of these field groups: Explicit packages with fixed +allocation | Explicit packages with seller-optimized allocation | Committed +proposal execution +``` + +**Construct a branch, not the base.** Constructing the base validates against +the branches, so `CreateMediaBuyRequest()` with no arguments raises — an empty +media buy satisfies none of the three ways to be one. Construct the branch class +whose shape you mean, and you get the stricter type for free: the branch +declares its required fields as required, and types each field it forbids as +`None`, so passing one is a type error rather than a runtime refusal. Once you +hold a branch you no longer need `getattr`, `hasattr` or `is not None` guards for +the fields that branch requires. + +**Subclass the base, or subclass one branch — but know which you want.** + +```python +from adcp.types import CreateMediaBuyRequestCommittedProposalExecution + +class InternalProposalExecution(CreateMediaBuyRequestCommittedProposalExecution): + """One branch, plus internal tracking.""" + + workflow_id: str | None = Field(default=None, exclude=True) +``` + +Subclassing a BRANCH gives a stricter type and keeps that branch's +requiredness. Subclassing the BASE gives a type every branch's document can +validate into, and your subclass keeps its own shape rather than the union's — +the dispatch belongs to the class that declares its branches, so a refinement +declared elsewhere is not swept into it. + +`Branches` is exported from `adcp.types` beside each public base that has +one — five today: `CreateMediaBuyRequestBranches`, +`ListAccountChangesResponseBranches`, `RequestProposalsRequestBranches`, +`VerifyBrandClaimRequestBranches` and `WebhookChallengeResponseBranches`. +Annotate a variable with it to make a type checker force exhaustive narrowing. +A union is exported only where the public name is a base over exactly those +branches, which is why the other eight roots have no alias: `adcp.types` never +exported those roots either, and `adcp.types.CreativeAsset` is the canonical +model refining ONE branch, so pairing a union with that name would promise a +supertype it is not. + +Each declaring module also builds a `BranchesAdapter`, the `TypeAdapter` +over its union, once at import. Import it from the module rather than from +`adcp.types`, which does not re-export it. + +The design, the proof that decides which roots are in this population, and what +it does not reach are in +[design/type-generation.md](design/type-generation.md#branches-that-partition-become-types-rules-that-do-not-partition-stay-checks). +This document does not repeat that reasoning. ## Scalar Schemas Are Plain Scalars @@ -105,7 +190,7 @@ assert issubclass(GetProductsResponse, ProtocolEnvelope) every root `allOf` `$ref` in the schema bundle and fails if the generated class does not descend from the referenced schema's class. Two shapes are refused rather than graded, because inheritance cannot express them — a referenced -schema that renders as a union of arms (`AssetVariant`, +schema that renders as a union of branches (`AssetVariant`, `ProductFormatDeclaration`), and one that renders as a value wrapper rather than an object, which is what a pure `if`/`then` constraint schema produces. Those compose as copied fields, and `TypeAdapter` is the way to validate them. @@ -255,11 +340,11 @@ exact and subclassed `TargetingOverlay` instances at runtime, preserving their object identity. These request classes are public; the nested control and purchase container classes are internal. Their compatibility union tries `TargetingOverlayInput` first with left-to-right validation, so raw dictionaries -still become mutation-input objects. The legacy arm is runtime-only: generated +still become mutation-input objects. The legacy branch is runtime-only: generated JSON Schema and MCP tool discovery still advertise only the mutation input and null. Invalid targeting produces the input schema's errors with request-document field paths, including direct Pydantic `ValidationError.errors()`. Python-mode -validation removes the bridge's synthetic union arm names and duplicate legacy +validation removes the bridge's synthetic union branch names and duplicate legacy errors at the targeting field boundary. Direct `model_validate_json()` uses the Input schema's native JSON validation, preserving its error types and order even when a canonical parent first materializes the request as Python objects. diff --git a/docs/reporting-production.md b/docs/reporting-production.md index dcf6f2d09..7750d1523 100644 --- a/docs/reporting-production.md +++ b/docs/reporting-production.md @@ -18,7 +18,7 @@ deploying these changes. They collect the historical comparison boundaries, feature-tier requirements and operational limits; final release qualification remains separate from the integrated source. -The default protocol is AdCP 3.2.1. Set `ReportingProductionSupport`'s +The default protocol is AdCP 3.2.3. Set `ReportingProductionSupport`'s `adcp_version="3.2"` or omit the pin for the packaged default. Historical rc.3, rc.6 and rc.7 schemas are not advertised as live reporting contracts. See [rc.7 adoption](protocol-3.2-rc7.md) and diff --git a/docs/reporting-receipt-ingress.md b/docs/reporting-receipt-ingress.md index ea63ea6bf..da792d0ad 100644 --- a/docs/reporting-receipt-ingress.md +++ b/docs/reporting-receipt-ingress.md @@ -94,7 +94,7 @@ path or rewrite receipt history to make an error disappear. ## Wire and replay contract Requests negotiate `adcp_version: "3.2"`. This is the wire release spelling; -`3.2.1` is the bundle's semantic-version spelling. The reporting handler's +`3.2.3` is the bundle's semantic-version spelling. The reporting handler's public mount pin also selects unnegotiated traffic; its default is 3.2. General SDK fallback behavior and unsupported-version rejection remain unchanged. Pinned, unpinned and fallback MCP inventories use an isolated schema overlay. diff --git a/docs/reporting-release-notes.md b/docs/reporting-release-notes.md index 575ad019e..e52b0dbc8 100644 --- a/docs/reporting-release-notes.md +++ b/docs/reporting-release-notes.md @@ -161,7 +161,7 @@ reauthorization and per-session destination authorization. ## Current and historical protocol versions Live reporting mounts and callers use AdCP `3.2`, whose bundle spelling is -`3.2.1`. Omit an explicit pin to use the packaged default, or configure the +`3.2.3`. Omit an explicit pin to use the packaged default, or configure the current supported reporting version consistently across the composition and its mounts. Cross-cohort reporting requests are rejected even when optional request validation is disabled. diff --git a/docs/schema-modifiers-and-class-checks.md b/docs/schema-modifiers-and-class-checks.md new file mode 100644 index 000000000..37fdce372 --- /dev/null +++ b/docs/schema-modifiers-and-class-checks.md @@ -0,0 +1,488 @@ +# Schema modifiers and the class-level checks they become + +A JSON Schema root says more than which properties it carries. It can say *one of these +combinations* (`anyOf`, `oneOf`), *if this field then that one* (`if`/`then`), *never these +two together* (`not`), or *this field only alongside that one* (`dependencies`). A Python +class is a flat field set with per-field requiredness, so the generator turns each of those +into a check. + +This document tells you, for a given schema construct, what the emitted check does at +validation time: what a refusal reports, which branch's errors you see, how a conditional +binds, and what nothing checks at all. + +[How the type tree is generated](design/type-generation.md) owns the other half — the +pipeline, the pre-generation transforms, the proof that decides whether a root splits into +branch classes or keeps a compiled check, the class populations, and the naming. Read that +one for how a check comes to exist, and this one for what it does once it exists. Where a +number belongs to the generator rather than to runtime behavior, this document points there +instead of repeating it. + +Measurements here come from `integrate/8qela` at `1c1e49b61`, under pydantic 2.13.5 — a +version this package's own pin admits (`pydantic>=2.13.0,<2.14`). Pydantic decides the union +label table below, so re-measure that table after a pydantic bump. Counts over generated +modules move with every schema refresh; take each as "last measured at" rather than current, +and re-derive with the command named beside it. + +Two sections describe behavior a landed fix changed, and each names the bead so you can tell +which side of it your build sits on. Where a fix is still in review, this document describes +the behavior at `1c1e49b61` and names the branch rather than predicting the outcome. + +## How a document reaches a branch + +A split root's public name carries its branches' core schema, so validating that name +returns the branch the document satisfies. `BranchDispatchMixin` +(`src/adcp/types/_branch_dispatch.py`) installs it, and `_branch_models` and +`_branch_combinator` on the generated base say which branches and which keyword. + +```python +FrequencyCapImpressionConstraints.model_validate({"allowed_values": [3]}) +# -> FrequencyCapImpressionConstraintsAllowedValues +``` + +The hook asks one question: does this class DECLARE `_branch_models` +(`"_branch_models" in cls.__dict__`)? A branch inherits the method, so a branch gets its own +shape back rather than the union, and so does any other subclass. + +### A subclass keeps the root's constraint and loses the dispatch + +This is the asymmetry to internalize, because the two halves go opposite ways and a reader +usually guesses that they travel together. Declare a subclass of a split root and you get: + +| What the subclass inherits | Outcome | +|---|---| +| The compiled root check (`_require_schema_required_group`) | Kept — pydantic inherits a model validator, so the root's conjuncts still refuse | +| The branches' core schema | Lost — the hook returns the subclass's own flat shape, so no branch dispatch | +| The union's wrapping validator, which supplies the combinator keyword | Lost with the schema | + +Measured on a bare subclass of `FrequencyCapImpressionConstraints`: + +```python +class Refined(FrequencyCapImpressionConstraints): ... + +TypeAdapter(FrequencyCapImpressionConstraints).validate_python({"allowed_values": [3]}) +# -> FrequencyCapImpressionConstraintsAllowedValues (dispatched) +TypeAdapter(Refined).validate_python({"allowed_values": [3]}) +# -> Refined (not dispatched) + +FrequencyCapImpressionConstraints.model_json_schema().keys() # ['$defs', 'anyOf'] +Refined.model_json_schema().keys() # ['$defs', 'additionalProperties', 'properties', ...] + +Refined.model_validate({"minimum": 1}) +# ValidationError, type 'oneOf': the inherited compiled check still refuses +``` + +Two consequences you meet in practice: + +- The public canonical creative types are such subclasses. `adcp.types.CreativeAsset` and + `adcp.types.CreativeManifest` declare no `_branch_models`, so neither dispatches, and both + render a flat JSON Schema (salesagent-8qela.108). +- A subclass reports a *cleaner* refusal than the root for the same document, because it runs + the compiled check directly instead of going through the union. The root answers `not` for + `{"minimum": 1}`; the subclass answers `oneOf`. The next section explains why. + +A branch class also inherits the method and skips dispatch, and there that is the design +rather than a gap: a branch has to get its own shape back. Of the 29 split types +`adcp.types` exports, 12 declare the method and dispatch, and 17 inherit it. Fifteen of +those 17 are branch classes, so the two that are neither a branch nor a dispatching root are +`CreativeAsset` and `CreativeManifest`. + +**What the lost dispatch costs you is the returned class and one refusal keyword, not +strictness.** Measured on `CreativeManifest` against the generated root, with every required +field supplied so only the combinator can fail: + +| Document | Generated root, dispatching | Public refinement, inheriting | +|---|---|---| +| Neither `format_id` nor `format_kind` | Refuses, `oneOf` | Refuses, `oneOf` | +| Both keys | Refuses, `not` | Refuses, `value_error` | +| `format_kind` only | Accepts as `CreativeManifestCanonicalFormatManifest` | Accepts as `CreativeManifest` | + +So there is no permissiveness gap to work around: the inheritor refuses what the branches +refuse and accepts what they accept, because the inherited compiled check does that work. +Two things do change. You lose the branch type on the way out, since the value validates as +the subclass rather than narrowing to an arm. And on a document that violates a branch's +forbid, the keyword degrades from `not` to `value_error`, which is not in the JSON Schema +vocabulary that `core/error.json` requires — the same class of defect as +salesagent-8qela.94. Annotate the generated root, or a branch, wherever either matters to +you. + +## What a refusal reports + +`_conditions.name_the_combinator` wraps the union and decides what you see. It groups the +union's errors by `loc[0]`, then classifies each group. + +A union's `loc` is not a document path. Pydantic prefixes every error with the label of the +choice that produced it, such as +`function-after[_require_schema_required_group(), AudienceCharacteristicValue]`. That label +names a class in this package rather than a key you sent, so `_in_the_document` drops it +positionally. Dropping it is what lets `issues[].pointer` resolve against your document +(salesagent-8qela.22, salesagent-8qela.28). + +The outcomes, in the order the wrapper decides them: + +1. **One group holds no branch-requiredness error.** That group's errors are reported, at + document paths. The wrapper treats this group as the branch you meant. +2. **Every group holds a branch-requiredness error.** One `PydanticCustomError` is raised + whose type is the root's combinator keyword, with the message `requires at least one of + these field groups: | <title>` and the absent field names in the error context. +3. **No group and no absent names.** The original error is re-raised unchanged. + +A branch-requiredness error is narrow: `_is_branch_requiredness` is `type == "missing"` and +`len(loc) == 2`, which is a required field at the branch's own root. A `missing` deeper than +that says the branch was reached and the document is wrong inside it. + +An empty document takes path 2 on every split root. All 19 name their own combinator for +`{}`. + +### A sibling branch's forbid displaces the real error + +Path 1's test is weaker than it reads, and the result misleads. A branch that FORBIDS a key +the document carries refuses in a `mode="before"` validator, which raises +`PydanticCustomError("not", ...)` as a whole-document error. That error is not `missing`, so +its group holds no branch-requiredness error, so path 1 reports it — even though that branch +is the one the document plainly did not mean. + +`core/audience-characteristic.json` is `anyOf(value | range)` with a root-required +`dimension`. Send one key and omit `dimension`: + +```python +AudienceCharacteristic.model_validate({"value": 1}) +# ValidationError, type 'not': "value is not accepted by this request shape" +``` + +The answer is the reverse of the truth. `value` is exactly what the Value branch requires, +and the actual fault — the missing `dimension` — never reaches you. Asked directly, the two +branches say: + +- `AudienceCharacteristicValue`: `missing ('dimension',)`, which is the real fault. +- `AudienceCharacteristicRange`: `not ()`, "value is not accepted", which is what you get. + +`_refuses_the_shape` already encodes the correct classification, and its own docstring calls +such a branch never reached. The wrapper consults it only on the degenerate-label path +described in the next section, so the ordinary path uses the weaker test. Ten of the 19 +split roots have branches that forbid keys, so all ten can answer this way. Filed as +salesagent-klblw, with the milder case on the same cause: +`FrequencyCapImpressionConstraints.model_validate({"minimum": 1})` answers "minimum is not +accepted by this request shape", when `minimum` is accepted in the `minimum+maximum` group +and the informative answer is that `maximum` is missing. + +Until that is fixed, read a `not` refusal from a split root as "some branch forbids this +key", never as "this key is unacceptable to the schema". + +A fix is in review on `fix/branch-election-filter`, which types the election filter on the +error keyword so a forbidding branch stops winning the refusal. The example above then +answers `anyOf` instead. Check whether your build carries that change before you rely on +either keyword. + +### The label-collapse hazard, for anyone extending the generator + +Grouping by `loc[0]` assumes pydantic gives each choice a distinct label, which nothing +guarantees. Measured on two branches under pydantic 2.13.5: + +| Branch schema | Labels | Verdict | +|---|---|---| +| Plain | `['First', 'Second']` | Distinct | +| After-validator only | `['function-after[f(), First]', 'function-after[f(), Second]']` | Distinct | +| Wrap only | `['function-wrap[f()]']` | Degenerate | +| After over wrap | `['function-after[f(), function-wrap[f()]]']` | Degenerate | + +**Any wrap validator in a branch's schema collapses every label to one.** A wrap's spelling +carries no class name, so all branches land in one group and the grouping carries no +information. + +When that happens, `_errors_of_the_reached_branch` asks the branches directly to decide which +one was reached. That selection matches on `loc` alone, because a branch asked directly +answers in python mode (`model_attributes_type`) where the union parsing JSON says +`dict_type` at the same path. The errors you receive still come from the union, in your own +input mode. The selector returns `None` — and the caller keeps the ungrouped group — when no +branch was reached, when two were, or when a branch accepts on its own while the union +refused. + +If you add a validator to a branch class, check its spelling against this table rather than +against the one case that exposed the hazard. The same table sits beside the fallback in +`_conditions.py`. + +## What a compiled root check does + +Everything a root states beyond its merged properties compiles into one +`model_validator(mode="after")` named `_require_schema_required_group`, as an expression over +the primitives in `src/adcp/types/_conditions.py`. +`scripts/compile_conditions.py`'s `render_root_validator` emits it. + +Two conventions run through every primitive: + +- **Presence is what the document carries.** `supplied` answers from `model_fields_set` + membership, and then from the serializer, because a default the class emits is carried by + the document exactly as a value you set is. `sent` adds the non-null half, because at a + position the schema does not mark nullable the client drops a null-valued key. Compiled + checks mostly call `sent`. +- **Every predicate is vacuous off-type.** JSON Schema applies a keyword only to instances of + the type it grades, so a primitive handed the wrong shape returns `True`. + +A check runs `mode="after"`, so it grades what pydantic parsed rather than what the document +carried. + +### A conjunct refusal names the file, the index, and its fields — in the message only + +``` +Product violates a constraint its schema states outside its properties +(core/product.json, root conjunct 0) over signal_targeting_allowed, +signal_targeting_options, signal_targeting_rules +``` + +The field names come from `compile_conditions.named_fields`, which walks the condition tree. + +**A conjunct refusal also carries those names in its error context**, so read them from +`ctx["fields"]` rather than parsing the sentence: + +```python +GetSignalsRequest.model_validate({}) +# type 'allOf', ctx {'fields': 'discovery_mode, signal_ids, signal_refs, signal_spec'} +``` + +Earlier builds passed no context on any compiled refusal, which left the field group +reachable only inside a message that also names the model class. That was a defect, fixed +under salesagent-8qela.49. If you read errors from a build predating that fix, `ctx` is +`None` on every compiled refusal and the sentence is your only source. + +Which refusals carry a context splits cleanly, over the 626 `PydanticCustomError` raise +sites under `src/adcp/types/domains/`: + +| Refusal | Keyword | Sites | Context | +|---|---|---|---| +| A root conjunct naming at least one field | `allOf` | 534 | `fields` | +| A root conjunct naming no field | `allOf` | 15 | None, and the message has no `over` clause | +| A flat root's combinator group | `anyOf`, `oneOf` | 45, 32 | None | + +The 15 are conditions whose `named_fields` walk returns nothing, such as a rule over +`keys()` or an array's items. `BiddingPolicy` is one: its message ends at `root conjunct 1`. + +Two runtime refusals outside that generated set carry context of their own. A split root's +union wrapper, `name_the_combinator`, passes `groups` and `fields` — so a combinator refusal +has a context when the root split into branch classes and none when it stayed flat. `_c.absent` +passes `fields` with keyword `not`. + +Every keyword above is in the JSON Schema vocabulary, which `core/error.json` requires of +every `issues[]` entry. A compiled condition that reaches the wire with keyword `validation` +is a separate open defect (salesagent-8qela.94). + +### `dependencies` compiles into root conjuncts + +`dependencies`, `dependentRequired`, and `dependentSchemas` compile into root conjuncts like +any other root content. Exactly **27** per-domain pinned documents carry one, all of them +using the draft-07 `dependencies` spelling — including `core/account.json`, +`core/canvas-constraint.json`, and `account/sync-accounts-request.json`. + +`core/product.json` carries none, which matters for the bound in the next section. + +The `mcp/2026-07-28/` projection is a different artifact and uses the 2020-12 +`dependentRequired` spelling across 284 documents. Count the per-domain set by walking each +document and counting the keyword in a schema position; a text search for `"dependencies"` +also matches a property named `dependencies`. + +## `_COMBINATOR_FIELDS`: what a consumer that filters a model must keep + +A root combinator makes its fields individually optional and collectively required, which +per-field `is_required()` cannot express. The generator therefore declares them on the class: + +```python +_COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'format_ids', 'format_options'}) +``` + +Keep these when you drop fields, or you produce a document the model refuses. The +`get_products` fields projection reads it for that reason: it kept the seven +individually-required `Product` fields, dropped `format_options` as optional and +unrequested, and the re-validation then failed `core/product.json`'s own +`sent(format_ids) or sent(format_options)`. + +**The name is the bound.** The attribute holds the combinator's fields, which is not the +complete set a root constraint can require. A disjunction with a negated presence arm — +`(not supplied(A)) or supplied(B)` — goes false when you drop `B` and keep `A`, and the +combinator never names those fields. That shape lives in the 27 schemas above rather than in +`core/product.json`. The complete rule is "every field whose absence can flip a predicate", +which needs a polarity walk over the condition tree. If that is ever needed it arrives as a +second attribute, without changing what this one means. + +The five public response bases declare no `_COMBINATOR_FIELDS` at all, because the +post-generation pass restores them over numbered classes rather than compiling a root check. +They are `ComplyTestControllerResponse`, `CreateContentStandardsResponse`, +`ListContentStandardsResponse`, `UpdateContentStandardsResponse`, and +`SyncGovernanceResponse`, and they are the five split-root modules that carry no compiled +check. + +## `if`/`then` binds only an instance that carries the key + +The pin states a conditional consequent as `then: {"properties": {"x": {...}}}`, and +`properties` applies only to an instance that carries the key. An instance omitting `x` +satisfies the conditional vacuously. Fourteen per-domain pinned documents carry a root-level +`if`, so predict your own schemas with this rule rather than memorizing the list. + +Two of them condition the response envelope's `status` on another field — +`protocol/sync-principal-response.json` and `protocol/get-principal-response.json`: + +```json +{"if": {"properties": {"result": {"properties": {"kind": {"const": "failed"}}}}}, + "then": {"properties": {"status": {"enum": ["failed", "rejected"]}}}} +``` + +**A class-emitted default makes the key carried.** `supplied` answers from the serializer as +well as from `model_fields_set`, because the two dumps are byte-identical and a constraint +whose verdict differed between them would grade something the wire cannot express. + +`post_generate_fixes.fix_protocol_envelope_status_default` gives the envelope +`status = TaskStatus.completed` for ergonomic construction, although +`core/protocol-envelope.json` makes `status` required with no default. So a response built +without one carries `completed`: + +```python +ProtocolEnvelope.model_validate({}).model_dump(mode="json", exclude_none=True) +# {'status': 'completed', 'replayed': False} +``` + +The conditional then binds that emitted value. Before presence included emitted defaults, +such a document passed because the key read as absent. + +So a default is not a free convenience on a field some conditional constrains. Supply the +field, as `src/adcp/server/principal.py` does at both of its failed-result sites +(`"status": "rejected"`, lines 189 and 405). + +## `additionalProperties`: three states, and `ignore` is always inherited + +`AdCPBaseModel` declares `extra='ignore'`, so every generated protocol type is forward +compatible by default. A generated class overrides that in two cases: + +| Schema shape | Declared policy | +|---|---| +| Open, or types `additionalProperties` | `extra='allow'` | +| A closed object schema | `extra='forbid'` | +| Anything else | Nothing declared, so `ignore` is inherited | + +**No generated class under `adcp.types.domains` declares `extra='ignore'`.** At `1c1e49b61` +the generated sources hold 1208 `extra='allow'` declarations and 1002 `extra='forbid'`, and +zero `ignore`. The only explicit `ignore` in `src/` sits on two hand-written OAuth models in +`src/adcp/oauth.py`. Treat `ignore` as the inherited default rather than as a fourth +declared tier. + +Effective policy over the 3044 model classes defined in modules under `adcp.types.domains`: +`allow` 1348, `forbid` 1118, `ignore` 482, and 96 that set no `extra` key and so take +pydantic's own default, which is also `ignore`. Effective counts exceed declared counts +because a subclass inherits its base's merged config. Read a policy off +`cls.model_config` for the effective value; read the generated source for what a class +declares. `cls.__dict__["model_config"]` answers the effective value too, because pydantic +merges the parent's config into every subclass, so it is never evidence that a class +declared anything. + +The policy decides where a branch's forbids can live. A branch class types a field it +forbids as `None`, which makes the combination a type error for a typed caller. A raw +document needs more, and the reason is the `allow` tier rather than the `forbid` one: under +`extra='allow'` an undeclared key is filed under extras and accepted, and a declared one +arrives as a `None` the branch does not forbid at a nullable position. So the refusal is a +`mode="before"` validator, `_refuse_keys_this_shape_forbids`, calling `_c.absent`. That is +also why such a refusal reaches you as a whole-document error rather than one at the +offending field, and why it displaces the combinator keyword. + +## Two tiers: canonical and legacy + +`adcp.types` exposes the **canonical** models. Legacy creative identity is absent from their +declared fields, JSON Schema, and serialized output at every nesting depth. Use this tier for +application code. + +`adcp.types.legacy` exposes the wire-faithful generated roots, which retain that identity +through the AdCP 3.x transition. Use it for migration, conformance tooling, and 3.0/3.1 wire +adapters. It exports 63 names under a `Legacy*` alias convention, so the generated +`CreativeAsset` root is `adcp.types.legacy.LegacyCreativeAsset`. The bare names are not +importable from that module. + +What the public canonical names bind differs between the two creative types, measured by MRO +membership rather than by annotation text: + +| Public name | Binds | +|---|---| +| `adcp.types.CreativeAsset` | A subclass of the canonical BRANCH `CreativeAssetV2CreativeCanonicalFormatKind` | +| `adcp.types.CreativeManifest` | A subclass of the generated ROOT, with no branch in its MRO | + +Neither declares `_branch_models`, so neither dispatches. Moving a name between tiers is an +export change rather than a design change. The tier split is stated in +`canonical_creative.py`'s module docstring, and this is the only other place it is written +down. + +## The advertised `inputSchema` is a different artifact from the pinned document + +An MCP client reads a tool's `inputSchema`, and that projection has its own contract. +`_model_to_json_schema` in `src/adcp/server/mcp_tools.py` requires an unconditional object +root for an input schema, because a discovery client has to render a form. With +`allow_root_union=False`, which is the input default, it refuses a root carrying any of +`$ref`, `allOf`, `anyOf`, `oneOf`, `not`, `if`, `then`, `else`, or `dependentSchemas`. +`tests/test_mcp_schema_drift.py::test_every_input_discovery_schema_has_unconditional_object_root` +asserts it. + +The pinned document is not that shape. `media-buy/create-media-buy-request.json` carries +`properties`, `required`, `anyOf`, `allOf`, and `dependencies` together at its root. So a +pin-faithful projection is exactly what the advertised-schema guard refuses, and "match the +pin" is the wrong instruction for this function. + +A split root is recoverable, and `_object_root_from_branches` recovers it from what the union +already carries. Each branch's `$defs` entry is a complete object schema, so properties are +unioned and `required` is intersected, and a field's declaration is the union of its +branches'. That is sound because a branch only ever narrows; taking the first branch's +declaration instead would advertise one branch's narrowing as the contract of all of them. + +The result is deliberately permissive about which branch a document must satisfy. A client +can build a payload satisfying none, because the advertised schema's job is to let a client +build a candidate and the server's job is to refuse a wrong one. The branches are still +enforced on arrival. A conditional or a composition at the root is not recoverable, so those +tools fall back to a hand-crafted shape. + +## What nothing checks + +**No grader compares `model_json_schema()` against the pinned document — anywhere.** A +reviewer who does not know this assumes the emitted schema is checked against the pin. It is +not. + +Seven test modules read both a pinned schema and call `model_json_schema()`, and every one of +them either asserts on individual properties or compares a model to another model: + +- `test_mcp_schema_drift` compares a tool's advertised schema to `model_json_schema()`. +- `test_spec_coverage` compares an MCP tool `inputSchema` to the request model's schema. +- `test_composability_invariant` compares two composed models' wire schemas to each other. +- `test_code_generation`, `test_rc6_adoption`, `test_version_scoped_models`, and + `test_targeting_overlay_schema` assert on named properties, `required` membership, or a + description. + +`tests/conformance/test_schema_model_parity.py` grades accept and refuse verdicts on +synthesized documents, which is behavior rather than the schema's own shape. + +Because nothing grades the document, these divergences sit unnoticed. Each one is open as +salesagent-8qela.90, and each reproduces on a top-level keyword comparison: + +| Divergence | What it means for you | +|---|---| +| `allOf` is dropped | A root's conditional rules are absent from the emitted schema. Runtime still enforces them through the compiled validator, so this is a documentation divergence rather than a validation one. | +| An inline `not` is dropped | A branch's forbids are absent from the emitted schema, though `_refuse_keys_this_shape_forbids` still enforces them. | +| `oneOf` with a `discriminator` becomes `anyOf` with none | `brand/verify-brand-claim-request.json`. `anyOf` accepts a document satisfying two branches where `oneOf` refuses it, so the emitted schema is **weaker** than the protocol states. It is sound only because the branches are pairwise disjoint, and that proof lives in the generator rather than in the emitted document. | +| `dependencies` is dropped | A presence implication the pin states is absent from the emitted schema. | + +Two things sharpen that list: + +- **The divergence is not confined to split roots.** `core/product.json` generates one flat + `Product` class that keeps `properties`, `required`, `type`, and `additionalProperties`, + and still drops both `allOf` and `anyOf`. +- **A split root's emitted schema is only `anyOf`.** It carries no top-level `type`, + `properties`, `required`, or `additionalProperties`, which is why three tools shipped stub + input schemas (salesagent-8qela.85). + +A guard comparing the two documents per root — top-level keyword sets, properties, +`required`, and the combinator keyword — catches all of these at once. + +## Known gaps, by bead + +The gaps this document describes are tracked as follows. + +| Gap | Bead | +|---|---| +| A sibling branch's `not` refusal displaces the real branch error and the combinator keyword | salesagent-klblw, fix in review on `fix/branch-election-filter` | +| An inheriting subclass of a split root does not dispatch, so a refusal names `allOf` where a root names `not` | salesagent-8qela.108 | +| A split root's generated JSON schema diverges from the pin in four ways, and nothing grades it | salesagent-8qela.90 | +| A split root's emitted schema has no top-level properties, so three tools ship stub input schemas | salesagent-8qela.85 | +| A generated root requires keys its schema root does not, masking the combinator refusal | salesagent-8qela.80 | +| A genuine union's generated member name reaches `error.field` | salesagent-8qela.46 | +| A compiled refusal carries a keyword outside the JSON Schema vocabulary | salesagent-8qela.94 | diff --git a/docs/type-surface.md b/docs/type-surface.md index 05b003710..b24767d69 100644 --- a/docs/type-surface.md +++ b/docs/type-surface.md @@ -199,7 +199,7 @@ Three classifiers, derived once from the MRO — there is no per-class table to They partition `model_fields`, which is pinned by a test. The tie-breaks follow the envelopes' own text: `context` is protocol, because the spec says the envelope declaration is authoritative where a body-level mirror also exists; a `status` narrowed to a `Literal` -on a payload arm is still protocol; `errors` is payload and `adcp_error` protocol, because +on a payload branch is still protocol; `errors` is payload and `adcp_error` protocol, because the spec says the two must be treated as distinct fields by name. `adcp_version` reports as version rather than payload even though the wire calls it payload, so that a transport composing a `DataPart` or an audit record gets tool arguments only — union the two sets if @@ -215,15 +215,15 @@ you want payload-including-version. | responses with `AdcpVersionEnvelope` | 70 / 77 | | responses with `ProtocolEnvelope` | 67 / 77 | -Counted per arm: a union-rooted message counts only when *every* class it can validate into -has the ancestry, because an arm is what a buyer actually receives. +Counted per branch: a union-rooted message counts only when *every* class it can validate into +has the ancestry, because an branch is what a buyer actually receives. The residues are the spec's, not the SDK's, and they are visible rather than papered over. `creative/validate-input-request.json` declares no version fields at all, so the SDK does not invent them; its `version_fields()` is empty while `get_adcp_version()` still answers, because the accessor reads the instance, not the MRO. The five compact media-buy responses compose an envelope-free shared response, and the two proposals responses carry a submitted -arm that declares no version fields. Ten task responses do not compose the protocol +branch that declares no version fields. Ten task responses do not compose the protocol envelope despite its own "REQUIRED on every task response envelope" claim. Each residue is pinned by equality in the SDK's guard, so a schema that gains the ancestry @@ -233,7 +233,7 @@ residue schema; run it against a checkout or an installed wheel. ## Response variations -A task response whose schema root is a union generates one class per arm and a plain +A task response whose schema root is a union generates one class per branch and a plain `TypeAlias` over them. There is no `RootModel` and no `.root`: ```python @@ -263,11 +263,21 @@ from pydantic import TypeAdapter response = TypeAdapter(CreateMediaBuyResponse).validate_python(raw) ``` -Validation resolves arms left to right. Where the schema gives an arm a discriminating +**Five response names are classes rather than aliases**, because callers construct them: +`ComplyTestControllerResponse`, `CreateContentStandardsResponse`, +`ListContentStandardsResponse`, `UpdateContentStandardsResponse` and +`SyncGovernanceResponse`. Each is a base over its numbered branches, so for those five the +public name has `model_validate`, it returns the branch the document satisfies on every +validation path, and `isinstance(branch, Base)` holds. Thirteen schema ROOTS are classes for +the same reason — see +[extending-types.md](extending-types.md#a-split-root-is-a-class-and-its-branches-subclass-it) +for which, and what that means for subclassing. + +Validation resolves branches left to right. Where the schema gives an branch a discriminating `const`, that becomes a defaulted `Literal` field and pydantic takes it as a fast path. But -most arms have no such tag: of the 58 numbered arm classes across the 22 union-rooted +most branches have no such tag: of the 58 numbered branch classes across the 22 union-rooted response modules, 47 declare no `status` field of their own and are distinguished by their -required sets instead — `create-media-buy-response.json`'s error arm is identified by +required sets instead — `create-media-buy-response.json`'s error branch is identified by requiring `errors` and nothing else. A synthetic discriminator field would make every union pydantic-discriminated, and is refused: it would be either a non-spec field on the wire or a serialization-excluded phantom that still shows up in `model_fields`, the extra policy and @@ -276,10 +286,10 @@ the idempotency hash. ### The numbered names, and what replaces them `CreateMediaBuyResponse1` is a positional name, and a positional name's meaning can change -without the thing it names changing: insert an arm into a `oneOf` upstream and -`CreateMediaBuyResponse2` becomes a different arm. +without the thing it names changing: insert an branch into a `oneOf` upstream and +`CreateMediaBuyResponse2` becomes a different branch. -The schema already carries better names — `create-media-buy-response.json` titles its arms +The schema already carries better names — `create-media-buy-response.json` titles its branches `CreateMediaBuySuccess`, `CreateMediaBuyError` and `CreateMediaBuySubmitted` — and `aliases.py` hand-transcribes most of them back today: @@ -287,16 +297,20 @@ The schema already carries better names — `create-media-buy-response.json` tit from adcp.types import CreateMediaBuySuccessResponse # == CreateMediaBuyResponse1 ``` -A rule that derives each arm's name from its own schema node now exists in the repository -and is graded, and the old-to-new table for all 58 numbered arm classes is derivable from -it. **The renames are not applied yet**, and 38 numeric-suffixed names are still on the flat -surface. Until they are, prefer the semantic alias over the numbered class where one exists, -and expect the numbered names to be removed in a future major with a deprecation shim and a -migration table. Writing new code against `CreateMediaBuyResponse2` is writing against the -drift channel. +A rule that derives each branch's name from its own schema node — the branch's `title`, +then a discriminator `const`, then its full `required` set, failing closed rather than +numbering — is wired for the thirteen schema roots whose branches partition by +requiredness. `CreateMediaBuyRequestCommittedProposalExecution` is a name from that rule. -See [design/type-generation.md](design/type-generation.md) for the rule itself and what -remains to wire it. +For the numbered RESPONSE classes it is not applied, and 39 numeric-suffixed names are +still on the flat surface. Prefer the semantic alias over the numbered class where one +exists, and expect the numbered names to be removed in a future major with a deprecation +shim and a migration table. Writing new code against `CreateMediaBuyResponse2` is writing +against the drift channel; the two surface-policy decisions wiring the rule for these names +needs are tracked in #1409. + +See [design/type-generation.md](design/type-generation.md#union-branch-names-schema-derived) +for the rule itself. ## Scalars, unions and composition, in one paragraph each @@ -305,9 +319,14 @@ covers each in full, including the subclassing rules. - **A scalar schema root is a plain scalar.** `PropertyTag` is a `str` subclass carrying the schema's constraints, not a `RootModel` wrapper. `tag == "sports"` is `True`. -- **An object-union root is a type alias.** Construct the arm you mean; use `TypeAdapter` - when the arm is not known in advance. A root that composes rather than naming a single - scalar keeps its `RootModel`; `isinstance(x, RootModel)` distinguishes them. +- **A nested object union is a type alias.** Construct the branch you mean; use + `TypeAdapter` when the branch is not known in advance. A root that composes rather than + naming a single scalar keeps its `RootModel`; `isinstance(x, RootModel)` distinguishes + them. +- **A schema root whose branches PARTITION is a class.** The public name is a base over one + class per branch; `model_validate` through it returns the branch the document satisfies, + and a document satisfying no branch is refused naming the root's JSON Schema keyword. + Thirteen roots, and five response names besides. - **A root `allOf` plus `$ref` is inheritance.** `GetProductsRequest` *is* an `AdcpVersionEnvelope`; the envelope's fields are not copied onto it. So an `isinstance` check or a generic boundary can bind to the composed base. diff --git a/scripts/branch_classes.py b/scripts/branch_classes.py new file mode 100644 index 000000000..13f863706 --- /dev/null +++ b/scripts/branch_classes.py @@ -0,0 +1,925 @@ +"""One class per branch, for a flat root whose branches partition. + +``scripts/schema_constraints.py`` reads a root's constraint content into +``Branch`` records and proves whether the branches partition the document space. +Where they do, the branches are not a rule over one class -- they are distinct +types, and the generator's flattening is what turned them into a rule. This +module renders that. + +The branches are ONE type with different requiredness, not several types, and the +emission says so: a shared base per split root -- ``<Root>Shape`` -- carries the +root's own properties, each branch subclasses it and restates only what it changes, and the +root's public name becomes a union over the branches. + +Measured over the eleven roots this splits, counting the properties the base +declares against the names any branch constrains at all (requires, forbids, or +pins): 31/5 on ``create-media-buy-request``, 21/8 on +``list-account-changes-response``, 17/2 on ``creative-asset``, down to 2/2 on +``webhook-challenge-response``. No root's base is empty, and no branch constrains +more than 7 of its root's properties -- the branches differ in which fields they +require and which they forbid, not in what the fields are. So none of these +roots is a union of unrelated classes, and a union of unrelated classes would +also have no base to subclass, which is what the two generated classes that +subclass a split root need. + +What that makes unconstructible, and why it is a different claim from a check: + +* A field an branch forbids is typed ``None`` on that branch, so + ``CreateMediaBuyRequestCommittedProposalExecution(packages=[...])`` is a type + error before anything runs. It is declared rather than omitted because the + base declares it: omitting it on the subclass would inherit the base's + permissive annotation. +* An branch's required field is required on that branch's class, so omitting it is a + missing argument rather than a validator's refusal. + +What stays a check, named rather than implied: everything in an branch's condition +that is not requiredness or absence -- a const one level down, an overlay on an +array's items, a value bound. Those are compiled onto the branch's own class by +``scripts/compile_conditions.py``, so the refusal still arrives from the class +the caller named, but it arrives at validation rather than at type-check time. + +Nothing here invents an identifier. Branch names come from +``scripts/union_branch_names.py``'s rungs over the same ``Branch`` records, and the +shared base is the root's name plus ``Shape``. + +The base is PUBLIC, and its visibility FOLLOWS its root's: a shape is exported +exactly when some public name resolves to the union over its branches, which is +five of the eleven today. ``adcp.types`` never exported the other six roots, so +a consumer cannot name those roots either. ``tests/test_branch_classes.py`` +asserts that as a set equality in both directions, which is how two errors in a +hand-picked export list were found. +""" + +from __future__ import annotations + +import ast +import re +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any + +try: + from compile_conditions import ( + ROOT_CONSTRAINT_VALIDATOR, + compile_condition, + schema_keyword_raise, + ) + from schema_constraints import ( + AllOf, + AnyOf, + Cond, + Not, + PropSatisfies, + Restated, + RootConstraints, + Supplied, + Undeclarable, + ValueConst, + ValueType, + branch_conditions, + ) + from union_branch_names import branch_class_names +except ModuleNotFoundError: # Imported as ``scripts.branch_classes`` in tests. + from scripts.compile_conditions import ( # type: ignore[assignment] + ROOT_CONSTRAINT_VALIDATOR, + compile_condition, + schema_keyword_raise, + ) + from scripts.schema_constraints import ( # type: ignore[assignment] + AllOf, + AnyOf, + Cond, + Not, + PropSatisfies, + Restated, + RootConstraints, + Supplied, + Undeclarable, + ValueConst, + ValueType, + branch_conditions, + ) + from scripts.union_branch_names import branch_class_names # type: ignore[assignment] + +__all__ = [ + "BranchPlan", + "plan_branches", + "render_absence_hook", + "render_root_required", + "repoint_subclasses_of", + "split_into_branch_classes", +] + + +@dataclass(frozen=True) +class BranchPlan: + """What one branch becomes: a field set, a requiredness, and what is left over.""" + + name: str + title: str | None + pointer: str + absent: frozenset[str] + """Names this branch forbids whose position the schema does NOT permit null at.""" + + absent_nullable: frozenset[str] + """Names it forbids at a position where a null survives to the document.""" + + required: frozenset[str] + literals: dict[str, Any] + residual: Cond + + +def _conjunction(operands: list[Cond]) -> Cond: + """The conjunction of what is left, without a one-element ``AllOf``.""" + if not operands: + return Restated("branch") + return operands[0] if len(operands) == 1 else AllOf(tuple(operands)) + + +def _conjuncts(condition: Cond) -> list[Cond]: + """Flatten a conjunction so each leaf can be read on its own.""" + if isinstance(condition, AllOf): + return [leaf for operand in condition.operands for leaf in _conjuncts(operand)] + return [condition] + + +def _absent_names(leaf: Cond) -> tuple[Supplied, ...] | None: + """The names a leaf forbids, or ``None`` when it forbids nothing. + + Two spellings, both in the bundle: ``not: {required: [x]}`` for one name and + ``not: {anyOf: [{required: [x]}, ...]}`` for several. Both say the document + may not carry the name, which on a class is a field typed ``None``. + + The ``Supplied`` records come back rather than bare names, because each + carries whether the schema permits null at that position -- and that decides + what "carry" means. Where null is forbidden the client drops a null-valued + key, so only a non-null value is carried; where it is permitted, presence is. + """ + if not isinstance(leaf, Not): + return None + inner = leaf.operand + if isinstance(inner, Supplied): + return (inner,) + if isinstance(inner, AnyOf) and all( + isinstance(operand, Supplied) for operand in inner.operands + ): + return tuple(inner.operands) # type: ignore[arg-type] + if isinstance(inner, AllOf) and all( + isinstance(operand, Supplied) for operand in inner.operands + ): + # ``not: {required: [a, b]}`` bans the PAIR, not either name. It becomes + # an absence only where the branch already requires one of them, which the + # caller resolves; here it is left to the residual check. + return None + return None + + +def _lifted_literal(leaf: Cond, fields: frozenset[str]) -> tuple[str, Any, Cond] | None: + """A field pinned to one value by this leaf, and what the leaf has left. + + ``{"properties": {"status": {"type": "string", "const": "completed"}}}`` + pins ``status``; the sibling ``type`` is the field's own annotation and + needs no check, but it is only dropped where the ``const`` it accompanies + becomes a ``Literal`` -- which is what makes dropping it sound. + """ + if not isinstance(leaf, PropSatisfies) or leaf.name not in fields: + return None + operands = leaf.operand.operands if isinstance(leaf.operand, AllOf) else (leaf.operand,) + pinned = [operand for operand in operands if isinstance(operand, ValueConst)] + if len(pinned) != 1: + return None + value = pinned[0].value + rest = [ + operand + for operand in operands + if not isinstance(operand, ValueConst) + and not (isinstance(operand, ValueType) and _value_has_type(value, operand.types)) + ] + remainder: Cond = Restated("literal") + if rest: + inner: Cond = rest[0] if len(rest) == 1 else AllOf(tuple(rest)) + remainder = PropSatisfies(leaf.name, inner) + return leaf.name, value, remainder + + +#: JSON type names, and whether a Python value answers to each. Only consulted +#: to decide whether a sibling ``type`` is implied by the ``const`` beside it, +#: so it is checked rather than assumed: a ``Literal['completed']`` annotation +#: carries ``type: string`` and nothing else does. +_JSON_TYPE_OF: dict[type, tuple[str, ...]] = { + bool: ("boolean",), + int: ("integer", "number"), + float: ("number",), + str: ("string",), + list: ("array",), + dict: ("object",), + type(None): ("null",), +} + + +def _value_has_type(value: Any, names: tuple[str, ...]) -> bool: + """Whether ``value`` satisfies every one of the named JSON types.""" + answered = _JSON_TYPE_OF.get(type(value), ()) + return bool(names) and all(name in answered for name in names) + + +def plan_branches( + constraints: RootConstraints, parent: str, schema_file: str, fields: frozenset[str] +) -> tuple[BranchPlan, ...]: + """What each branch becomes, from the records and nothing else. + + ``fields`` are the attribute names the flat class declares, so a predicate + naming a key no field holds stays in the residual check rather than being + read as a field to drop. + """ + names = branch_class_names(parent, constraints.branches, schema_file) + plans: list[BranchPlan] = [] + for branch, name, condition in zip( + constraints.branches, names, branch_conditions(constraints), strict=True + ): + absent: set[str] = set() + absent_nullable: set[str] = set() + required: set[str] = set(branch.required) + literals: dict[str, Any] = {} + residual: list[Cond] = [] + for leaf in _conjuncts(condition): + banned = _absent_names(leaf) + if banned is not None and {node.name for node in banned} <= fields: + for node in banned: + (absent_nullable if node.nullable else absent).add(node.name) + continue + if isinstance(leaf, Supplied) and leaf.name in fields: + required.add(leaf.name) + continue + if isinstance(leaf, Undeclarable): + # False of every instance, so an branch carrying it positively is + # dead and the emitter must not pretend otherwise. + residual.append(leaf) + continue + lifted = _lifted_literal(leaf, fields) + if lifted is not None: + field_name, value, remainder = lifted + literals[field_name] = value + if not isinstance(remainder, Restated): + residual.append(remainder) + continue + residual.append(leaf) + plans.append( + BranchPlan( + name=name, + title=branch.title, + pointer=branch.pointer, + absent=frozenset(absent - required), + absent_nullable=frozenset(absent_nullable - required), + required=frozenset(required), + literals=literals, + residual=_conjunction(residual), + ) + ) + return tuple(plans) + + +# --------------------------------------------------------------------------- # +# Rendering: the flat class's statements, per branch +# --------------------------------------------------------------------------- # + + +def _on_the_type_half(annotation: ast.expr, change: Callable[[ast.expr], ast.expr]) -> ast.expr: + """Apply ``change`` to the TYPE half of an annotation, ``Annotated`` or not. + + An ``Annotated[T, Field(...)]`` carries the type in its first slice element + and the field metadata after it, so changing the type means rebuilding the + subscript around a changed first element -- and every rewrite in this module + needs that same rebuild. :func:`_literal_annotation` keeps its own copy: for + an ``Annotated`` whose slice is not a tuple it falls through to the bare + literal rather than to the annotation unchanged, and a fallback parameter + invented to express a difference on an input ``Annotated`` cannot have + (it needs two arguments) would cost more than the copy. + """ + if isinstance(annotation, ast.Subscript) and _name_of(annotation.value) == "Annotated": + if isinstance(annotation.slice, ast.Tuple) and annotation.slice.elts: + elements = list(annotation.slice.elts) + elements[0] = change(elements[0]) + return ast.Subscript( + value=annotation.value, + slice=ast.Tuple(elts=elements, ctx=ast.Load()), + ctx=annotation.ctx, + ) + return annotation + return change(annotation) + + +def _strip_none(annotation: ast.expr) -> ast.expr: + """``X | None`` -> ``X``, inside an ``Annotated`` too. Required means no null. + + A required field whose schema also permits ``null`` keeps it: the union is + only stripped where ``None`` is the optionality the generator added for a + field the root did not require. + """ + return _on_the_type_half(annotation, _without_none) + + +def _without_none(annotation: ast.expr) -> ast.expr: + """``X | None`` -> ``X`` on a bare union, recursing through ``Annotated``.""" + if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr): + if _is_none(annotation.right): + return _strip_none(annotation.left) + if _is_none(annotation.left): + return _strip_none(annotation.right) + return ast.BinOp( + left=_strip_none(annotation.left), + op=annotation.op, + right=_strip_none(annotation.right), + ) + return annotation + + +def _literal_annotation(annotation: ast.expr, value: Any) -> ast.expr: + """Replace the type half of an annotation with ``Literal[value]``.""" + literal = ast.Subscript( + value=ast.Name(id="Literal", ctx=ast.Load()), + slice=ast.Constant(value=value), + ctx=ast.Load(), + ) + if isinstance(annotation, ast.Subscript) and _name_of(annotation.value) == "Annotated": + if isinstance(annotation.slice, ast.Tuple) and annotation.slice.elts: + elements = list(annotation.slice.elts) + elements[0] = literal + return ast.Subscript( + value=annotation.value, + slice=ast.Tuple(elts=elements, ctx=ast.Load()), + ctx=annotation.ctx, + ) + return literal + + +def _is_none(node: ast.expr) -> bool: + return isinstance(node, ast.Constant) and node.value is None + + +def _name_of(node: ast.expr) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _field_name(statement: ast.AnnAssign) -> str | None: + return statement.target.id if isinstance(statement.target, ast.Name) else None + + +def _statement_source(statement: ast.stmt, source: str) -> str: + """One statement exactly as written, decorators and comments included. + + ``ast.get_source_segment`` starts a decorated function at its ``def``, so + copying a validator through it silently drops the ``@model_validator`` that + makes it run -- a validator that still reads as one and enforces nothing. + The span is taken from the first decorator instead. + """ + lines = source.splitlines() + decorators = getattr(statement, "decorator_list", []) + start = min([node.lineno for node in decorators] + [statement.lineno]) - 1 + end = statement.end_lineno or start + 1 + block = lines[start:end] + indent = min( + (len(line) - len(line.lstrip()) for line in block if line.strip()), + default=0, + ) + return "\n".join(line[indent:] if line.strip() else "" for line in block) + + +def _base_name(flat_class: str) -> str: + """The base takes the ROOT'S PLAIN NAME, so it needs no suffix at all. + + The base is what a consumer constructs, parses through and tests with + ``isinstance``, because it carries the branches' core schema -- so the + root's own name belongs on it. Only the union needs a name of its own, + and :func:`_branches_name` gives it one. + + An earlier spelling suffixed the base and bound the root's name to the + union. That made the public name uncallable, which broke two tests the repo + already merged to assert what a consumer does, and required editing every + call site to say the same thing in more words. + """ + return flat_class + + +def _branches_name(flat_class: str) -> str: + """The union over the branches: the root's name plus ``Branches``. + + Exported beside the base, because the two say different things. A consumer + annotating the base says "a request of this kind"; one annotating the union + says "exactly one of these", which is what makes a type checker force + exhaustive narrowing. + """ + return f"{flat_class}Branches" + + +def render_branch_models(branch_names: Sequence[str], combinator: str) -> list[str]: + """Declare the branches, which is how the shared hook finds them. + + ``BranchDispatchMixin`` carries the one implementation, and the base is the + class that DECLARES this method -- so declaring it is what makes a class the + base, and a branch or a refinement elsewhere inherits it and keeps its own + shape. Nothing has to be registered, so nothing can be forgotten: a new + split root works by emitting this method. + + This replaced a hook emitted per root, which was eleven copies of one + function. Two bugs were found in that function while it was still one copy. + + Both emitters call this one renderer. The branch emitter compiles a split + root's branches out of the schema's branches; the response emitter adopts the + numbered classes ``datamodel-code-generator`` already wrote for a root the + pre-pass leaves combined. The two arrive at a base and its subclasses by + different routes and declare them in the same words. + """ + listed = ",\n ".join(branch_names) + return [ + "", + " @classmethod", + " def _branch_models(cls) -> tuple[type, ...]:", + " # The branches subclass this class, so they are not defined while", + " # this body is compiled. The hook resolves them on first use, after", + " # the model_rebuild below.", + " return (", + f" {listed},", + " )", + "", + " @classmethod", + " def _branch_combinator(cls) -> str:", + " # The keyword a refusal names, from the root's own schema. The", + " # union the hook builds carries the wrapper that supplies it.", + f" return {combinator!r}", + ] + + +def _add_none(annotation: ast.expr) -> ast.expr: + """``X`` -> ``X | None``, inside an ``Annotated`` too. The inverse of :func:`_strip_none`. + + Optionality is spelled the way ``datamodel-code-generator`` spells it for a + property the root does not require -- ``T | None`` annotation, ``None`` + default -- so a relaxed field on the base is indistinguishable from one the + generator never merged. + """ + return _on_the_type_half(annotation, _with_none) + + +def _with_none(annotation: ast.expr) -> ast.expr: + """``X`` -> ``X | None`` on a bare type, left alone if it already admits one.""" + if _admits_none(annotation): + return annotation + return ast.BinOp(left=annotation, op=ast.BitOr(), right=ast.Constant(value=None)) + + +def _admits_none(annotation: ast.expr) -> bool: + """Whether a union already carries ``None``, so adding it would repeat it.""" + if _is_none(annotation): + return True + if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr): + return _admits_none(annotation.left) or _admits_none(annotation.right) + return False + + +def _relax(statement: ast.AnnAssign) -> str: + """One required field statement, re-rendered as the optional field it is.""" + rebuilt = ast.AnnAssign( + target=statement.target, + annotation=_add_none(statement.annotation), + value=ast.Constant(value=None), + simple=1, + ) + return ast.unparse(ast.fix_missing_locations(rebuilt)) + + +def render_root_required(root_required: frozenset[str]) -> list[str]: + """The root's own required fields, declared on the base for consumers to read. + + ``_ABSENT`` is the shape: the emitter already declares a per-class frozenset + of the keys a shape forbids, and this is the same mechanism for the keys the + ROOT requires. It exists because the distinction cannot be recovered + downstream -- a combinator refusal lists every absent field the branches + report, mixing root-required misses in with branch-selecting ones, and an + intersection over the branches' errors cannot separate them: measured + against each root's pinned ``required`` list that derivation agrees on 8 of + 18 roots and fails in both directions (salesagent-8qela.116). + + EVERY field the root requires, not only the ones this class declares. A + first version intersected with the base's own statements and lost + ``SearchBrandsResponse.status``, which the root requires through its + ``allOf`` of ``core/protocol-envelope`` and the base inherits from + ``ProtocolEnvelope`` without redeclaring -- so a document omitting it would + have had that miss read as branch-selecting, which is the defect this set + exists to close. Which class holds the field is not what the set is about. + """ + names = ", ".join(repr(name) for name in sorted(root_required)) + return [ + "", + f" _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({{{names}}})", + ] + + +def _render_base( + flat: ast.ClassDef, + flat_class: str, + schema_file: str, + source: str, + branch_names: Sequence[str], + combinator: str, + root_required: frozenset[str], +) -> str: + """The root's own shape: every property and everything other passes added. + + This is what makes the branches one type rather than several. Measured over + the eleven partitioning roots: every one has properties shared by all of its + branches (29 on ``create-media-buy-request``, 17 on ``creative-asset``, 2 on + the smallest), and eight of the eleven have branches that constrain NO + property at all -- they differ only in which fields they require and which + they forbid. So the faithful rendering is one base and a subclass per branch; + a union of unrelated classes would say the branches are different types, + which the schema does not say anywhere in this population. + + The body is copied verbatim, so every earlier pass's field metadata, + validator and config lands on the base and is inherited once rather than + repeated per branch. + """ + # The mixin is listed FIRST so its __get_pydantic_core_schema__ wins over + # BaseModel's. model_config merges right-to-left with the right-most base + # winning, so prepending it does not disturb the config the generated bases + # resolve -- which is the same ordering fact the canonical layer depends on. + bases = ", ".join(["BranchDispatchMixin", *(ast.unparse(base) for base in flat.bases)]) + members = "\n".join(f" ``{name}``" for name in branch_names) + lines = [ + f"class {_base_name(flat_class)}({bases}):", + f' """What every branch of {schema_file} shares.', + "", + " The root's own properties and requiredness. Each branch subclasses this", + " and states only what it changes, so the branches are one type with", + " different requiredness rather than unrelated classes:", + "", + members, + "", + " This class carries the branches' CORE SCHEMA, so parsing it -- by", + " name, nested in another model, or through a TypeAdapter -- returns", + " the branch the document satisfies, and refuses a document that", + " satisfies none or two of them.", + "", + f" {_branches_name(flat_class)} is the union over those branches, for a", + " caller that wants a type checker to force exhaustive narrowing.", + ' """', + "", + ] + body = 0 + for statement in flat.body: + if isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant): + continue # the flat class's own docstring, replaced above + if isinstance(statement, ast.FunctionDef) and statement.name == ROOT_CONSTRAINT_VALIDATOR: + # A compiled check over the WHOLE combinator, left by an earlier run + # of the constraint pass. On the base it would re-ask of every branch + # what the branch's own shape answers, and its return annotation would + # name the union. Each branch's residual check is emitted below instead. + continue + rendered = _statement_source(statement, source) + if _relaxes(statement, root_required): + # The flat class is datamodel-code-generator's MERGE of the root with + # its branches, and the merge carries a branch's requiredness onto the + # root: where every branch requires a key the root does not, the + # merged class requires exactly the key its branches SELECT BY + # (salesagent-8qela.80). A document omitting it then fails FIELD + # validation, so the root's own combinator check never runs and the + # refusal names ``missing`` instead of the keyword. + # + # So the base takes the SCHEMA ROOT's requiredness and each branch + # keeps its own -- which costs nothing, because every branch already + # redeclares the keys it requires. + rendered = _relax(statement) + lines.extend(f" {line}" if line.strip() else "" for line in rendered.splitlines()) + body += 1 + if not body: + lines.append(" pass") + lines.extend(render_root_required(root_required)) + lines.extend(render_branch_models(branch_names, combinator)) + return "\n".join(lines) + + +def _relaxes(statement: ast.stmt, root_required: frozenset[str]) -> bool: + """Whether this statement is a required field the SCHEMA ROOT does not require. + + Keyed on the statement having no default, which is how + ``datamodel-code-generator`` spells required, and on the name being absent + from the root's own required set. A ``ClassVar`` carries a value and so is + never a candidate. + """ + if not isinstance(statement, ast.AnnAssign) or statement.value is not None: + return False + name = _field_name(statement) + return name is not None and name not in root_required + + +def _render_branch(plan: BranchPlan, flat: ast.ClassDef, flat_class: str, schema_file: str) -> str: + """One branch: only what it changes about the base. + + A required field is redeclared without its default, a pinned field as the + ``Literal`` the branch pins it to, and a forbidden field as ``None`` -- which + is what makes passing it a type error rather than a validator's refusal. + Everything else is inherited, so the branch's class says exactly what the branch + says and nothing more. + """ + declared = { + name: statement + for statement in flat.body + if isinstance(statement, ast.AnnAssign) + for name in (_field_name(statement),) + if name is not None + } + heading = plan.title or f"The {plan.pointer} branch" + lines = [ + f"class {plan.name}({_base_name(flat_class)}):", + f' """{heading}.', + "", + f" One branch of {schema_file}, stated as a shape: {plan.pointer} requires", + " what it declares below without a default, and types what it forbids", + " as ``None``.", + ' """', + "", + ] + changed = 0 + for name in sorted(plan.absent | plan.absent_nullable): + statement = declared.get(name) + if statement is None: + continue + lines.append(f" {name}: None = None") + changed += 1 + for name in sorted(plan.required | set(plan.literals)): + statement = declared.get(name) + if statement is None: + continue + rendered = _render_field(statement, plan) + lines.extend(f" {line}" if line.strip() else "" for line in rendered.splitlines()) + changed += 1 + if not changed: + lines.append(" pass") + return "\n".join(lines) + + +def _render_field(statement: ast.AnnAssign, plan: BranchPlan) -> str: + """One field statement, re-annotated for this branch.""" + name = _field_name(statement) + annotation = statement.annotation + value = statement.value + if name is not None and name in plan.literals: + annotation = _literal_annotation(annotation, plan.literals[name]) + value = None + if name is not None and name in plan.required: + annotation = _strip_none(annotation) + value = None + rebuilt = ast.AnnAssign(target=statement.target, annotation=annotation, value=value, simple=1) + return ast.unparse(ast.fix_missing_locations(rebuilt)) + + +def render_absence_hook(plan: BranchPlan) -> str: + """The branch's forbidden keys, refused on the document as sent. + + Absence is a type at CONSTRUCTION -- the field is not on the class, so the + keyword argument does not exist -- and it is this hook at WIRE VALIDATION, + because a model configured ``extra='allow'`` would otherwise file the key + under extras and accept a document its schema refuses. The design names + this as the one place the type system alone cannot carry an branch's content: + the type system does not see raw documents. + """ + if not (plan.absent or plan.absent_nullable): + return "" + names = ", ".join(repr(name) for name in sorted(plan.absent)) + nullable = ", ".join(repr(name) for name in sorted(plan.absent_nullable)) + return "\n".join( + [ + "", + f" _ABSENT: ClassVar[frozenset[str]] = frozenset({{{names}}})", + f" _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({{{nullable}}})", + "", + " @model_validator(mode='before')", + " @classmethod", + " def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any:", + " # The fields this branch forbids are typed None above, which is what", + " # makes the combination a type error. A raw document still needs", + " # this: extra='allow' files an undeclared key under extras and", + " # accepts, and a declared one would arrive as a None the branch does", + " # not forbid at a nullable position.", + " return _c.absent(", + " data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE)", + " )", + ] + ) + + +def _render_validator(plan: BranchPlan, schema_file: str, combinator: str) -> str: + """The branch's residual check, or the empty string when nothing is left. + + The refusal names the root combinator, because that is the keyword a + consumer puts in ``error.issues[].keyword`` -- the branch's residual is part of + what the root's ``anyOf``/``oneOf`` states. A bare ``ValueError`` arrives as + ``value_error``, names no keyword, and ``core/error.json`` requires one; a + seller reported exactly that reaching the wire for ``core/creative-asset``, + which is one of the roots this emitter splits. + """ + predicate = compile_condition(plan.residual) + if predicate == "True": + return "" + refusal = ( + f"{plan.name} violates what {schema_file} states for its {plan.pointer} branch " + f"beyond the fields it requires and forbids" + ) + return "\n".join( + [ + "", + " @model_validator(mode='after')", + f" def {ROOT_CONSTRAINT_VALIDATOR}(self) -> {plan.name}:", + f" # Compiled from {schema_file} {plan.pointer}: what this branch states", + " # that its field set cannot carry -- a value one level down, an", + " # overlay on an array's items, a bound. Requiredness and absence", + " # are the class's own shape and are not re-checked here.", + " if not (", + f" {predicate}", + " ):", + schema_keyword_raise(combinator, refusal, indent=" " * 12), + " return self", + ] + ) + + +def split_into_branch_classes( + source: str, + flat_class: str, + constraints: RootConstraints, + schema_file: str, + fields: frozenset[str], +) -> str | None: + """Rewrite a module's flat root class into one class per branch plus a union. + + ``None`` when the module does not hold the flat class as a plain class + statement, which is the one shape this rewrite understands. + """ + tree = ast.parse(source) + flat = next( + (node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == flat_class), + None, + ) + if flat is None: + return None + + plans = plan_branches(constraints, flat_class, schema_file, fields) + blocks = [ + _render_base( + flat, + flat_class, + schema_file, + source, + [plan.name for plan in plans], + constraints.combinator or "anyOf", + constraints.root_required, + ) + ] + blocks.extend( + _render_branch(plan, flat, flat_class, schema_file) + + render_absence_hook(plan) + + _render_validator(plan, schema_file, constraints.combinator or "anyOf") + for plan in plans + ) + union = " | ".join(plan.name for plan in plans) + combinator = constraints.combinator or "anyOf" + branches = _branches_name(flat_class) + blocks.append( + "\n".join( + [ + f"#: {schema_file}'s branches, proven pairwise disjoint, so a parsed", + "#: document comes back as exactly one of them. Annotate this to make a", + f"#: type checker force exhaustive narrowing; annotate {flat_class} to", + '#: say "a request of this kind" and get the same strictness at runtime.', + "#:", + "#: The wrapper names the root combinator on a refusal. A document that", + "#: satisfies no branch fails each branch at field validation, so pydantic", + "#: reports 'missing' and nothing names the keyword core/error.json", + "#: requires in issues[].keyword.", + f"{branches}: TypeAlias = Annotated[", + f" {union},", + " WrapValidator(", + f" _c.name_the_combinator({combinator!r}, " + f"{flat_class}._branch_models(), {flat_class}._ROOT_REQUIRED)", + " ),", + "]", + "", + # No module-level ``TypeAdapter`` for the union. It used to be emitted + # here as "Built once, at import", which is precisely what + # ``test_sdk_has_no_module_level_type_adapter_construction`` forbids: a + # ``TypeAdapter`` builds its validator AT IMPORT, so the schema state is + # captured once and shared by every test in the process, and that + # guard's subject is "no schema state may leak in from another test". + # + # Making it lazy was measured and REJECTED: with a per-module + # ``__getattr__`` the module-level-assignment count went 1 -> 0 and the + # guard went green, while importing ``adcp.types._generated`` -- which + # re-exports all thirteen EAGERLY -- still built them. That converts a + # visible violation into an invisible one. + # + # Deleted rather than relocated because nothing consumed it: over the + # tracked tree there were 14 declarations and 110 references, and all 96 + # others were re-export imports or ``__all__`` strings -- zero + # ``validate_python``, zero ``dump_*``, in src, tests and scripts alike. + # None of the fourteen was reachable from ``adcp.types`` or present in + # its ``__all__``; they existed only inside a module whose own docstring + # reads "INTERNAL ... DO NOT import from this module directly". A caller + # who wants one writes ``TypeAdapter(XBranches)``, which is the line this + # was saving. The UNION alias above is untouched and the publicly + # reachable ones stay reachable. + f"# {flat_class} is declared above its branches, so _branch_models", + "# cannot resolve them while the class body is compiled. The rebuild is", + "# what installs the branches' schema -- a class's core schema is built", + "# at class creation, so the hook's earlier return is already cached.", + "#", + "# This is not a surviving patch. What this series deletes is a model", + "# patched from a FOREIGN module: a field installed that the class never", + "# declared, with a forced rebuild to make the patch take. A rebuild in", + "# the declaring module, resolving a forward reference the class cannot", + "# name at declaration time, is what model_rebuild is for.", + f"{flat_class}.model_rebuild(force=True)", + ] + ) + ) + + lines = source.splitlines(keepends=True) + start = min([decorator.lineno for decorator in flat.decorator_list] + [flat.lineno]) - 1 + end = flat.end_lineno or len(lines) + replaced = "\n\n\n".join(blocks) + "\n" + return "".join(lines[:start]) + replaced + "".join(lines[end:]) + + +def branch_names(constraints: RootConstraints, parent: str, schema_file: str) -> Sequence[str]: + """The class names this module would emit, without rendering anything.""" + return branch_class_names(parent, constraints.branches, schema_file) + + +def repoint_subclasses_of(source: str, flat_class: str) -> str | None: + """Repoint a class that subclasses a split root onto the root's shared base. + + A union is not a base, so a generated subclass of a root the emitter splits + has to name the base instead. There are two in the pinned bundle -- + ``Creative(CreativeAsset)`` and ``CreativeRepresentation(CreativeManifest)`` + -- and nothing subclasses either of those in turn, so this does not recurse. + + ``None`` when the module names the class in no base list, which is every + module but the one or two that do. + + The subclass is NOT a member of the union afterwards, and that is the honest + reading: it is a different shape, with its own fields, and a consumer + annotating the union never meant to accept it. What it does get is the + fields every branch shares, which is what it was reaching for by subclassing + the flat class. + """ + base = _base_name(flat_class) + tree = ast.parse(source) + lines = source.splitlines(keepends=True) + edits: list[tuple[int, str, str]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + for reference in node.bases: + target = reference.value if isinstance(reference, ast.Subscript) else reference + named = target.id if isinstance(target, ast.Name) else getattr(target, "attr", None) + if named != flat_class: + continue + spelling = ast.unparse(target) + edits.append((node.lineno - 1, spelling, spelling.replace(flat_class, base))) + if not edits: + return None + for index, old, new in edits: + lines[index] = lines[index].replace(f"({old})", f"({new})").replace(f"({old},", f"({new},") + rewritten = "".join(lines) + return _repoint_import(rewritten, flat_class, base) + + +def _repoint_import(source: str, flat_class: str, base: str) -> str: + """Make the base importable wherever the flat class was imported by name. + + The import is ADDED rather than replaced: the module may still reference the + union as a field annotation, which stays valid. + """ + + if re.search(rf"^class {re.escape(flat_class)}\b", source, re.MULTILINE): + # Declared in this very module, so the base is declared here too. + return source + pattern = re.compile( + rf"^(from [^\n]*? import )([^\n(]*\b{re.escape(flat_class)}\b[^\n]*)$", re.MULTILINE + ) + match = pattern.search(source) + if match is None: + # The class was rewritten to name a base that nothing imports, which is + # a module that fails at import. Raised rather than returned, like the + # pipeline's other import helpers: the generated shape changed in a way + # this rewrite no longer matches -- a parenthesized import block, say -- + # and that is a regeneration failure, not something to leave quiet. + raise RuntimeError(f"no single-line 'import {flat_class}' statement to extend with {base}") + names = [name.strip() for name in match.group(2).split(",") if name.strip()] + if base in names: + return source + statement = match.group(1) + ", ".join(sorted({*names, base})) + return source[: match.start()] + statement + source[match.end() :] diff --git a/scripts/compile_conditions.py b/scripts/compile_conditions.py new file mode 100644 index 000000000..afb6af5c2 --- /dev/null +++ b/scripts/compile_conditions.py @@ -0,0 +1,591 @@ +"""Compile a ``Cond`` tree into the Python a generated model carries. + +``scripts/schema_constraints.py`` reads a schema root's constraint content into +a ``Cond`` tree. This module turns one of those trees into an expression over +``src/adcp/types/_conditions.py``'s primitives and ordinary Python, and wraps a +root's branches and conjuncts into the ``model_validator`` body the generator +appends to the class. + +It invents no identifiers. Per-branch predicates are expressions inside the one +validator, every reusable part is a fixed name in the vocabulary module, and a +lambda parameter is named after the field it binds -- so the only names the +generator mints are class names, which come from the branch-identity rule. Titles +ride as schema-derived text in the refusal message and in comments. +""" + +from __future__ import annotations + +import keyword +from collections.abc import Sequence +from typing import Any + +#: The JSON Schema keywords an injected runtime check may name. +#: +#: ``core/error.json`` requires every ``issues[]`` entry to carry a ``keyword`` +#: "drawn from the JSON Schema vocabulary" and says not to invent values there, +#: so a check names the keyword of the construct it restores and nothing else. +#: Membership is checked rather than documented because an invented keyword +#: reaches the wire looking exactly like a real one. +SCHEMA_KEYWORDS = frozenset( + { + "allOf", + "anyOf", + "const", + "contains", + "dependentRequired", + "else", + "enum", + "format", + "if", + "maxItems", + "maxLength", + "maximum", + "minItems", + "minLength", + "minimum", + "not", + "oneOf", + "pattern", + "propertyNames", + "required", + "then", + "type", + "uniqueItems", + } +) + + +def schema_keyword_raise( + keyword: str, message: str, *, indent: str = " " * 8, fields: Sequence[str] = () +) -> str: + """Render the ``raise`` statement an injected runtime check refuses with. + + ``PydanticCustomError`` sets pydantic's error ``type`` to its first argument + and keeps the message verbatim, so ``e.errors()[0]["type"]`` is the schema + keyword that rejected the payload — which is what a consumer needs for + ``error.issues[].keyword``. A bare ``ValueError`` becomes ``value_error``, + which names no keyword, and a consumer assembling a conformant envelope then + has to drop the issue and refuse the buyer with no structured reason. + + The caller passes the keyword the SCHEMA states, never one read off the + message: the English is written for a human and two different keywords can + share a phrasing. + + *fields* are the field names the refusal cites, and they go in the error + CONTEXT rather than only in the message. The authored sentence carries them + too, but a consumer cannot read them out of it: the same string also names + the model class, which is producer-internal, so the projection that strips + the class name has to discard the whole sentence and render the keyword + alone (``adcp.validation.pydantic_errors._message``). The context is the one + place the names arrive structured, and the projection reads them from + ``ctx['fields']`` — a comma-joined string, which is the spelling + ``adcp.types._conditions`` already uses for its own forbidden-name refusal. + + Omitted entirely when there are no names. A condition that reads no field is + ordinary, and ``{'fields': ''}`` would put an empty parenthetical in front of + the buyer where the keyword alone reads correctly. + """ + if keyword not in SCHEMA_KEYWORDS: + raise ValueError(f"{keyword!r} is not a JSON Schema keyword an issue may name") + context = f"{indent} {{'fields': {', '.join(fields)!r}}},\n" if fields else "" + return ( + f"{indent}raise PydanticCustomError(\n" + f"{indent} {keyword!r},\n" + f"{indent} {message!r},\n" + f"{context}" + f"{indent})" + ) + + +try: + from schema_constraints import ( + AllOf, + AnyOf, + Branch, + Cond, + DependentRequired, + EveryItem, + IfThen, + KeysSatisfy, + MatchingKeysSatisfy, + NoOtherProps, + Not, + OneOf, + OtherPropsSatisfy, + PropCount, + PropSatisfies, + Restated, + RootConstraints, + SomeItem, + Supplied, + Undeclarable, + ValueBounds, + ValueConst, + ValueEnum, + ValueItems, + ValueLen, + ValuePattern, + ValueType, + ValueUnique, + ) +except ModuleNotFoundError: # Imported as ``scripts.compile_conditions`` in tests. + from scripts.schema_constraints import ( # type: ignore[assignment] + AllOf, + AnyOf, + Branch, + Cond, + DependentRequired, + EveryItem, + IfThen, + KeysSatisfy, + MatchingKeysSatisfy, + NoOtherProps, + Not, + OneOf, + OtherPropsSatisfy, + PropCount, + PropSatisfies, + Restated, + RootConstraints, + SomeItem, + Supplied, + Undeclarable, + ValueBounds, + ValueConst, + ValueEnum, + ValueItems, + ValueLen, + ValuePattern, + ValueType, + ValueUnique, + ) + +__all__ = [ + "CONDITIONS_IMPORT", + "COMBINATOR_FIELDS", + "GROUP_MESSAGE_PREFIX", + "ROOT_CONSTRAINT_VALIDATOR", + "branch_label", + "compile_condition", + "named_fields", + "render_root_validator", +] + +#: The import a module carrying a compiled validator needs. +CONDITIONS_IMPORT = "from adcp.types import _conditions as _c" + +#: The validator's name. Kept across the change from a required-names-only +#: check to the whole root constraint, because the name is what +#: ``tests/test_validation_parity.py`` derives its graded population from -- +#: membership is "the generator put the validator on this model", read off +#: ``__pydantic_decorators__``, not a list of ids kept there. +ROOT_CONSTRAINT_VALIDATOR = "_require_schema_required_group" + +#: Two contract tests match on this wording +#: (``tests/test_codegen_contract_compatibility.py``). The branches' schema titles +#: are appended to it; changing the prefix breaks those tests for a +#: non-behavioural reason. +GROUP_MESSAGE_PREFIX = "requires at least one of these field groups" + +#: The subject expression at the top of a validator body. +_SELF = "self" + +#: The lambda parameter bound to an array element or a mapping member. A fixed +#: vocabulary name rather than a singular invented from the field's name. +_ELEMENT = "item" +_KEY = "name" + +#: Where a compiled expression is wrapped onto further lines. +_WIDTH = 96 + + +def _parameter(name: str) -> str: + """A lambda parameter named after the field it binds.""" + return f"{name}_" if keyword.iskeyword(name) or name in (_SELF, _ELEMENT, _KEY) else name + + +def _grouped(expression: str) -> str: + """Parenthesise an expression only where precedence needs it. + + A conjunction of simple membership tests reads as one; wrapping every + operand turns the emitted branch into brackets. + """ + depth = 0 + for index, character in enumerate(expression): + if character in "([{": + depth += 1 + elif character in ")]}": + depth -= 1 + elif depth == 0 and expression.startswith((" and ", " or ", " if "), index): + return f"({expression})" + return expression + + +def _bounds(call: str, subject: str, **bounds: Any) -> str: + named = ", ".join(f"{key}={value!r}" for key, value in bounds.items() if value is not None) + return f"_c.{call}({subject}{', ' + named if named else ''})" + + +def compile_condition(condition: Cond, subject: str = _SELF) -> str: # noqa: C901, PLR0911, PLR0912 + """One ``Cond`` as a Python expression over ``subject``.""" + if isinstance(condition, Restated): + # Already enforced by the class's own declaration, so true of every + # instance -- which is what makes substituting it sound here and under + # a negation alike. + return "True" + if isinstance(condition, Undeclarable): + # A key no field holds on a model that FORBIDS extras: pydantic refuses + # the document before the validator runs, so the predicate is false of + # every instance that reaches here. Which is what makes the branch + # forbidding the key satisfiable and the branch requiring it dead. + return "False" + + if isinstance(condition, Supplied): + # ``supplied`` is membership; ``sent`` adds the non-null half where the + # schema forbids null, because the client drops such a key on the way + # out and the document that arrives does not carry it. + primitive = "supplied" if condition.nullable else "sent" + return f"_c.{primitive}({subject}, {condition.name!r})" + + if isinstance(condition, Not): + return f"not ({compile_condition(condition.operand, subject)})" + if isinstance(condition, AllOf): + return " and ".join( + _grouped(compile_condition(operand, subject)) for operand in condition.operands + ) + if isinstance(condition, AnyOf): + return " or ".join( + _grouped(compile_condition(operand, subject)) for operand in condition.operands + ) + if isinstance(condition, OneOf): + branches = ", ".join(compile_condition(operand, subject) for operand in condition.operands) + return f"[{branches}].count(True) == 1" + if isinstance(condition, IfThen): + consequent = ( + compile_condition(condition.consequent, subject) + if condition.consequent is not None + else "True" + ) + alternative = ( + compile_condition(condition.alternative, subject) + if condition.alternative is not None + else "True" + ) + test = compile_condition(condition.condition, subject) + return f"({_grouped(consequent)} if {_grouped(test)} else {_grouped(alternative)})" + + if isinstance(condition, PropSatisfies): + bound = _parameter(condition.name) + inner = compile_condition(condition.operand, bound) + return f"_c.descend({subject}, {condition.name!r}, lambda {bound}: {inner})" + if isinstance(condition, EveryItem): + inner = compile_condition(condition.operand, _ELEMENT) + return f"_c.every({subject}, lambda {_ELEMENT}: {inner})" + if isinstance(condition, SomeItem): + inner = compile_condition(condition.operand, _ELEMENT) + return f"_c.some({subject}, lambda {_ELEMENT}: {inner})" + if isinstance(condition, KeysSatisfy): + inner = compile_condition(condition.operand, _KEY) + return f"_c.keys({subject}, lambda {_KEY}: {inner})" + if isinstance(condition, MatchingKeysSatisfy): + inner = compile_condition(condition.operand, _ELEMENT) + return f"_c.matching_keys({subject}, {condition.pattern!r}, lambda {_ELEMENT}: {inner})" + if isinstance(condition, NoOtherProps): + return f"_c.only({subject}, {list(condition.names)!r})" + if isinstance(condition, OtherPropsSatisfy): + inner = compile_condition(condition.operand, _ELEMENT) + return f"_c.others({subject}, {list(condition.names)!r}, lambda {_ELEMENT}: {inner})" + + if isinstance(condition, ValueConst): + return f"{subject} == {condition.value!r}" + if isinstance(condition, ValueEnum): + return f"{subject} in {list(condition.values)!r}" + if isinstance(condition, ValueType): + names = ", ".join(repr(name) for name in condition.types) + return f"_c.json_type({subject}, {names})" + if isinstance(condition, ValuePattern): + return f"_c.matches({subject}, {condition.pattern!r})" + if isinstance(condition, (ValueLen, ValueItems)): + return _bounds("length", subject, minimum=condition.minimum, maximum=condition.maximum) + if isinstance(condition, PropCount): + # A count of members, not a ``len``. ``_c.length`` cannot measure a + # pydantic object at all, so routing these two through it emitted a + # validator that graded nothing -- the fourth closed door behind + # salesagent-09ghh, and the one that made the other three look sufficient. + return _bounds("members", subject, minimum=condition.minimum, maximum=condition.maximum) + if isinstance(condition, ValueBounds): + return _bounds( + "within", subject, ge=condition.ge, le=condition.le, gt=condition.gt, lt=condition.lt + ) + if isinstance(condition, ValueUnique): + return f"_c.unique({subject})" + + if isinstance(condition, DependentRequired): + trigger = f"_c.supplied({subject}, {condition.name!r})" + dependents = " and ".join(f"_c.supplied({subject}, {name!r})" for name in condition.names) + return f"not ({trigger}) or {_grouped(dependents)}" + + raise TypeError(f"no emission for {type(condition).__name__}") + + +def named_fields(condition: Cond) -> tuple[str, ...]: + """Every field name a condition reads, sorted, for the refusal to name. + + Schema-derived: these are the properties the node itself mentions, not text + this generator composes. A refusal that names only the schema file and the + conjunct's index tells a caller which rule fired and nothing about where to + look; ``agent-notification-config.json``'s conditional reads ``event_types`` + and ``all_authorized_accounts``, and naming them is the difference between a + diagnostic and a citation. + """ + found: set[str] = set() + + def walk(node: Cond) -> None: + if isinstance(node, (Supplied, Undeclarable)): + found.add(node.name) + elif isinstance(node, PropSatisfies): + found.add(node.name) + walk(node.operand) + elif isinstance(node, DependentRequired): + found.add(node.name) + found.update(node.names) + elif isinstance(node, (NoOtherProps, OtherPropsSatisfy)): + found.update(node.names) + if isinstance(node, OtherPropsSatisfy): + walk(node.operand) + elif isinstance(node, Not): + walk(node.operand) + elif isinstance(node, (AllOf, AnyOf, OneOf)): + for operand in node.operands: + walk(operand) + elif isinstance(node, IfThen): + walk(node.condition) + for branch in (node.consequent, node.alternative): + if branch is not None: + walk(branch) + elif isinstance(node, (EveryItem, SomeItem, KeysSatisfy, MatchingKeysSatisfy)): + walk(node.operand) + + walk(condition) + return tuple(sorted(found)) + + +def branch_label(branch: Branch) -> str: + """How an branch names itself in a refusal message. + + Schema-derived text in all three cases: the branch's own title, else the field + group it requires, else its pointer. A title is both more readable than a + synthesized field list and the thing the schema actually says. + """ + if branch.title: + return branch.title + if branch.required: + return "+".join(branch.required) + return branch.pointer + + +def _wrap(expression: str, indent: str) -> str: + """Break a long expression at its top-level ``and``/``or`` boundaries.""" + if len(expression) + len(indent) <= _WIDTH: + return f"{indent}{expression}" + parts: list[str] = [] + depth = 0 + current = "" + for token in expression.split(" "): + if token in {"and", "or"} and depth == 0 and current: + parts.append(current) + current = token + continue + current = f"{current} {token}" if current else token + depth += token.count("(") - token.count(")") + depth += token.count("[") - token.count("]") + if current: + parts.append(current) + if len(parts) == 1: + return f"{indent}{expression}" + return f"\n{indent}".join(parts).join((indent, "")) + + +def render_root_validator( + constraints: RootConstraints, + class_name: str, + schema_file: str, + subject: str = _SELF, +) -> str: + """The ``model_validator`` body a flat root's class carries. + + One validator per root. The combinator check comes first, then each + conjunct beside it, each with the refusal the schema's own words support. + Returns the empty string when the root states nothing a predicate can + carry, so a class gains no validator for a constraint that is already a + type. + + ``subject`` is what the document is on this class. For a model it is the + model; for a ``RootModel`` over an open value it is ``self.root``, and the + predicates then read that value's members as a mapping -- which is what the + vocabulary's two-shape handling is for. + """ + body: list[str] = [] + if constraints.branches: + body.extend(_render_combinator(constraints, class_name, subject)) + for index, conjunct in enumerate(constraints.conjuncts): + body.extend(_render_conjunct(conjunct, class_name, schema_file, index, subject)) + if not body: + return "" + + return ( + "\n".join( + [ + *_render_root_constraint_fields(constraints), + "", + " @model_validator(mode='after')", + f" def {ROOT_CONSTRAINT_VALIDATOR}(self) -> {class_name}:", + f" # Compiled from {schema_file}: everything the root states beyond", + " # its merged properties, which datamodel-code-generator drops.", + " #", + " # Presence is membership in model_fields_set, which is what", + " # ``required`` asks. An explicit null is a supplied value -- on a", + " # mutation input it is the command to clear -- and a default the", + " # caller never sent is not.", + *body, + " return self", + ] + ) + + "\n" + ) + + +#: The attribute a root carries naming the fields its COMBINATOR reads. Named for the +#: combinator specifically, NOT for root constraints in general: see +#: :func:`_render_root_constraint_fields` for what it excludes and why the narrower +#: name is the point rather than an abbreviation. +COMBINATOR_FIELDS = "_COMBINATOR_FIELDS" + + +def _render_root_constraint_fields(constraints: RootConstraints) -> list[str]: + """The combinator's fields, declared on the class for consumers to read. + + A root ``anyOf``/``oneOf`` says "at least one of these groups", so each named + field is INDIVIDUALLY OPTIONAL and COLLECTIVELY REQUIRED -- a distinction + ``model_fields[...].is_required()`` cannot express, because it is per field. + Any consumer deriving a keep-set from per-field requiredness therefore drops + one of them and produces a document the model refuses. + + Measured on ``get_products``: the fields projection kept the 7 fields + ``Product`` requires individually, dropped ``format_options`` because it is + individually optional, and the re-validation then failed the root's own + ``sent(format_ids) or sent(format_options)`` -- ten tests, one cause + (salesagent-8qela.86). + + THE COMBINATOR'S FIELDS ONLY, not every field the constraints read. Measured + on ``Product``: its 12 conjuncts read 20 of its 59 fields, so declaring all + of them would have a buyer asking for ``product_id`` alone receive 27 fields + -- it passes the suite and defeats the feature. The combinator's 2 are + sufficient: all ten tests pass, and the eight that already passed stay + passing, including the one grading that an unrequested field IS dropped. + + THE NAME IS THE BOUND, and it is narrow on purpose. This is NOT the complete + set of fields a root constraint can require. A conjunct can also flip on + absence -- a disjunction with a NEGATED presence arm, + + (not supplied(A)) or supplied(B) + + goes false when ``B`` is dropped and ``A`` is kept. Measured on the pin: 27 + per-domain schemas carry a presence-implication keyword (``dependencies``, + ``dependentRequired``, ``dependentSchemas``) -- ``core/media-buy.json`` over + ``accepted_proposal_id``, ``core/error.json`` over ``buyer_reason``, + ``core/delivery-recipient.json`` over ``cloud`` and ``region``, among others + -- and ``core/product.json``, the one this was built for, carries NONE. So + the gap is a fact about the pin and not a theoretical ceiling, and a consumer + reading this attribute for any of those 27 would be reading an incomplete + answer under a complete-sounding name. + + Hence ``_COMBINATOR_FIELDS`` rather than ``_ROOT_CONSTRAINT_FIELDS``. The + complete rule is "every field whose absence can flip a predicate", which + needs a polarity walk over the condition tree; if anyone ever needs it, it + arrives as a SECOND attribute without changing what this one means. Nobody + has to discover the bound by being bitten by it. + """ + fields = sorted( + {name for branch in constraints.branches for name in named_fields(branch.condition)} + ) + if not fields: + return [] + names = ", ".join(repr(name) for name in fields) + return [ + "", + f" {COMBINATOR_FIELDS}: ClassVar[frozenset[str]] = frozenset({{{names}}})", + ] + + +def _render_combinator(constraints: RootConstraints, class_name: str, subject: str) -> list[str]: + labels = [branch_label(branch) for branch in constraints.branches] + # The branch's own condition, not the branch conjoined with the root's conjuncts: + # each conjunct is rendered once below, with the refusal naming it. The + # conjoined form is what the disjointness prover and the branch-class + # emitter need, where a conjunct has to distribute into an branch's shape. + predicates = [compile_condition(branch.condition, subject) for branch in constraints.branches] + + lines = [" if not ("] + for index, (label, predicate) in enumerate(zip(labels, predicates, strict=True)): + lines.append(f" # {label}") + lines.append(f" {'' if index == 0 else 'or '}(") + lines.append(_wrap(predicate, " " * 16)) + lines.append(" )") + lines.append(" ):") + message = f"{class_name} {GROUP_MESSAGE_PREFIX}: {' | '.join(labels)}" + # The keyword the SCHEMA states, never one read off the message: a consumer + # needs it for ``error.issues[].keyword``, which ``core/error.json`` requires + # and tells it not to invent. A bare ValueError arrives as ``value_error``, + # names no keyword, and the consumer then drops the issue and refuses the + # buyer with nothing structured. This is #1435, which landed in beta.3 on + # the function this series replaced -- the keyword is still known here, + # because the reader records which combinator the branches came from. + lines.append(schema_keyword_raise(constraints.combinator or "anyOf", message, indent=" " * 12)) + + # ``oneOf`` is exactly one, which is derivable now that each branch's + # predicate is its whole condition. The required-names-only reading could + # not derive it, and that is the reason its docstring gave for not trying. + # An branch carrying ``x-adcp-validation`` is excepted: its cardinality + # depends on rules declared outside the structural models' promise. + if ( + constraints.combinator == "oneOf" + and len(constraints.branches) > 1 + and not constraints.declares_behavioral_rules + ): + satisfied = ", ".join(f"({predicate})" for predicate in predicates) + exclusive = ( + f"{class_name} satisfies more than one of these mutually exclusive " + f"field groups: {' | '.join(labels)}" + ) + lines.append(f" if [{satisfied}].count(True) != 1:") + lines.append(schema_keyword_raise("oneOf", exclusive, indent=" " * 12)) + return lines + + +def _render_conjunct( + conjunct: Cond, class_name: str, schema_file: str, index: int, subject: str +) -> list[str]: + predicate = compile_condition(conjunct, subject) + if predicate == "True": + return [] + named = named_fields(conjunct) + over = f" over {', '.join(named)}" if named else "" + message = ( + f"{class_name} violates a constraint its schema states outside its " + f"properties ({schema_file}, root conjunct {index}){over}" + ) + # A root conjunct is an ``allOf`` member: that is the keyword a consumer + # reads for a constraint the root states beside its combinator. + # + # ``named`` goes to the context as well as into the sentence. The sentence + # cannot deliver them: it also names the model class, so the projection that + # strips producer-internal text drops the whole string and the buyer was + # told "allOf composition failed" with no place to look. One derivation, + # both destinations (salesagent-8qela.49). + return [ + " if not (", + _wrap(predicate, " " * 12), + " ):", + schema_keyword_raise("allOf", message, indent=" " * 12, fields=named), + ] diff --git a/scripts/consolidate_exports.py b/scripts/consolidate_exports.py index fddc793c5..d879c6022 100644 --- a/scripts/consolidate_exports.py +++ b/scripts/consolidate_exports.py @@ -592,7 +592,7 @@ def _stem_matches_export(module_stem: str, export_name: str) -> bool: # the pre-rebind declaration, so a whole shifted window of names typed one # variant off what they held. aliases: dict[str, str] = {} - # AdCP renumbered the adagents authorization variants when it added an arm at + # AdCP renumbered the adagents authorization variants when it added an branch at # the front. Each historical public name keeps the shape it has always had, # and the root union gets its own name. if {"AuthorizedAgents", "AuthorizedAgents6"}.issubset(all_exports): @@ -630,7 +630,7 @@ def _stem_matches_export(module_stem: str, export_name: str) -> bool: # FooResponse model. Keep the old numbered names as aliases when the # upstream generator no longer emits them so legacy imports continue to # work while resolving to the beta 3 shape. - response_arm_aliases = { + response_branch_aliases = { "AcquireRightsResponse1": "AcquireRightsResponse", "AcquireRightsResponse2": "AcquireRightsResponse", "AcquireRightsResponse3": "AcquireRightsResponse", @@ -689,7 +689,7 @@ def _stem_matches_export(module_stem: str, export_name: str) -> bool: "ValidateContentDeliveryResponse1": "ValidateContentDeliveryResponse", "ValidateContentDeliveryResponse2": "ValidateContentDeliveryResponse", } - for alias, target in response_arm_aliases.items(): + for alias, target in response_branch_aliases.items(): if alias not in all_exports and target in all_exports: aliases[alias] = target # The beta 3 product schema is a oneOf over two concrete product shapes. @@ -807,10 +807,14 @@ def _stem_matches_export(module_stem: str, export_name: str) -> bool: "from adcp.types import domains # noqa: F401", "", "# Rebuild models that reference other models via forward refs", - "# Note: only call model_rebuild() on actual classes, not Union type aliases", + "# A name here can be a union alias over a split root's branches, which has", + "# no model_rebuild of its own. classes_behind is the one derivation of", + "# what a name stands for: the shared base and each branch, or the model.", + "from adcp.types._split_root import classes_behind", ] for t in rebuild_types: - rebuild_lines.append(f"{t}.model_rebuild()") + rebuild_lines.append(f"for _rebuilt in classes_behind({t}):") + rebuild_lines.append(" _rebuilt.model_rebuild()") rebuild_lines.append("") lines.extend(rebuild_lines) @@ -910,6 +914,33 @@ def _schema_module_paths() -> list[Path]: return paths +#: The runtime half of a lazy domain facade, emitted verbatim into each one. +#: +#: Under ``not TYPE_CHECKING`` so a type checker never sees a module-level +#: ``__getattr__``: one would type every unknown name as ``object`` and silence +#: the missing-attribute error the ``TYPE_CHECKING`` block above exists to keep. +_LAZY_FACADE_RESOLVER = ''' +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = { + name: module for module, names in _DECLARED_BY.items() for name in names + } + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) +''' + + def generate_domain_exports( bindings: dict[str, dict[str, str]], name_to_modules: dict[str, set[str]], @@ -953,6 +984,14 @@ def generate_domain_exports( "A type this domain declares in more than one schema is not here: import", " it from its own schema's module," f" ``adcp.types.domains.{domain}.<schema>``.", "Nothing here is renamed.", + "", + "This facade is LAZY (PEP 562). It binds no name at import time, because an", + "eager one makes every leaf of this domain depend on every other leaf: a", + "submodule import runs this ``__init__``, so one leaf's dependencies become", + "the whole domain's. That turned a benign submodule-level cycle between", + "``core`` and ``formats.canonical`` -- two domains whose schemas reference", + "each other, which Python handles -- into a package-level one that made all", + "16 modules under ``formats/canonical/`` unimportable on their own.", ] lines.extend( [ @@ -963,13 +1002,38 @@ def generate_domain_exports( "# ruff: noqa: E501, I001", "from __future__ import annotations", "", + "from typing import TYPE_CHECKING", + "", + "if TYPE_CHECKING:", + " # The whole importable surface, so type checkers and IDEs see it. Runtime", + " # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd", + " # import is still an error rather than ``object``.", ] ) for module_name in sorted(by_module): lines.append( - f"from adcp.types.domains.{module_name} import " + f" from adcp.types.domains.{module_name} import " f"{', '.join(sorted(by_module[module_name]))}" ) + lines.extend( + [ + "", + "#: Which schema module declares each re-exported name.", + "#:", + "#: Resolution is PER NAME, a deliberate divergence from", + "#: ``adcp.types.__init__``, which routes every name through one ``_eager``", + "#: module. Importing a whole domain graph on first access can re-enter the", + "#: cycle this laziness exists to break, when that first access happens while", + "#: a canonical format module is still initializing. Importing only the one", + "#: declaring module cannot.", + "_DECLARED_BY = {", + ] + ) + for module_name in sorted(by_module): + declared = ", ".join(f'"{name}"' for name in sorted(by_module[module_name])) + lines.append(f' "{module_name}": ({declared},),') + lines.append("}") + lines.extend(_LAZY_FACADE_RESOLVER.splitlines()) lines.extend(_format_all_block(sorted(rows))) modules[domain] = "\n".join(lines) diff --git a/scripts/export_resolution.py b/scripts/export_resolution.py index d2c7539cc..e5f85cfe2 100644 --- a/scripts/export_resolution.py +++ b/scripts/export_resolution.py @@ -55,7 +55,7 @@ def resolution_key(obj: object) -> str: A class or function is its defining module and qualname — the pair that changes when a name repoints. A submodule is its own path. A union is the - sorted keys of its members, so a union gaining, losing or repointing an arm + sorted keys of its members, so a union gaining, losing or repointing an branch shows up too. Anything else falls back to its type, which tracks presence without inventing a value that churns between runs. """ diff --git a/scripts/generate_ergonomic_coercion.py b/scripts/generate_ergonomic_coercion.py deleted file mode 100644 index 7701a78e0..000000000 --- a/scripts/generate_ergonomic_coercion.py +++ /dev/null @@ -1,726 +0,0 @@ -#!/usr/bin/env python3 -"""Generate _ergonomic.py by introspecting generated Pydantic models. - -This script analyzes the generated Pydantic types to auto-generate the type -coercion rules in _ergonomic.py. It uses runtime introspection of the actual -models to detect fields that need coercion. - -Coercion patterns detected: -1. Enum fields -> coerce_to_enum -2. list[Enum] fields -> coerce_to_enum_list (or order-preserving unique coercion) -3. ContextObject fields -> coerce_to_model(ContextObject) -4. ExtensionObject fields -> coerce_to_model(ExtensionObject) -5. list[BaseModel] fields -> coerce_subclass_list (for subclass variance) -""" - -from __future__ import annotations - -import argparse -import sys -from enum import Enum -from pathlib import Path -from typing import Any, get_args, get_origin - -# Add src to path so we can import the types -REPO_ROOT = Path(__file__).parent.parent -sys.path.insert(0, str(REPO_ROOT / "src")) - -OUTPUT_FILE = REPO_ROOT / "src" / "adcp" / "types" / "_ergonomic.py" - -# Types to analyze for coercion -# These are the main request types users construct -REQUEST_TYPES_TO_ANALYZE = [ - "ListCreativeFormatsRequest", - "ListCreativeFormatsRequestCreativeAgent", - "ListCreativesRequest", - "PackageRequest", - "CreateMediaBuyRequest", -] - -# Response types to analyze for coercion -RESPONSE_TYPES_TO_ANALYZE = [ - "GetProductsResponse", - "ListCreativesResponse", - "ListCreativeFormatsResponse", - "CreateMediaBuyResponse1", - "UpdateMediaBuyResponse1", - "GetMediaBuyDeliveryResponse", -] - -# Nested types that also need coercion -NESTED_TYPES_TO_ANALYZE = [ - ("Sort", "creative.list_creatives_request"), - ("GetProductsRequest", "media_buy.get_products_request"), - ("PackageUpdate", "media_buy.package_update"), -] - -# Types that should get subclass_list coercion (for list variance) -SUBCLASS_LIST_TYPES = { - # Request list types - "CreativeAsset", - "CreativeAssignment", - "PackageRequest", - # Response list types - "Product", - "Creative", - "Format", - "Package", - "MediaBuyDelivery", - "Error", - "CreativeAgent", -} - - -def get_base_type(annotation: Any) -> Any: - """Extract the base type from Optional/Union annotations. - - For X | None, returns X. - For non-union types, returns the type as-is. - """ - origin = get_origin(annotation) - if origin is type(None): - return None - - # Handle Union types (including Optional which is Union[X, None]) - # Check if it's a union by looking at origin - import types - - if origin is types.UnionType: - args = get_args(annotation) - # Filter out None type - non_none = [a for a in args if a is not type(None)] - if len(non_none) == 1: - return non_none[0] - return annotation - - # Not a union - return as-is - return annotation - - -def is_list_of(annotation: Any, item_check) -> tuple[bool, Any]: - """Check if annotation is list[X] or Sequence[X] where X passes item_check. - - Handles both T[X] and T[X] | None (where T is list or collections.abc.Sequence). - """ - from collections.abc import Sequence as AbcSequence - - _list_origins = (list, AbcSequence) - - # First check if the annotation itself is a list/Sequence - origin = get_origin(annotation) - if origin in _list_origins: - args = get_args(annotation) - if args and item_check(args[0]): - return True, args[0] - - # Then check if it's Optional[list[X]] or Optional[Sequence[X]] - base = get_base_type(annotation) - if base is not None and base is not annotation: - origin = get_origin(base) - if origin in _list_origins: - args = get_args(base) - if args and item_check(args[0]): - return True, args[0] - - return False, None - - -def analyze_model(model_class) -> list[dict]: - """Analyze a Pydantic model and return fields needing coercion.""" - from pydantic import BaseModel - - coercions = [] - - # Import the specific types we check for - from adcp.types.domains.core.context import ContextObject - from adcp.types.domains.core.ext import ExtensionObject - - for field_name, field_info in model_class.model_fields.items(): - annotation = field_info.annotation - base_type = get_base_type(annotation) - - if base_type is None: - continue - - # Check for enum field - if isinstance(base_type, type) and issubclass(base_type, Enum): - coercions.append( - { - "field": field_name, - "type": "enum", - "target_class": base_type, - } - ) - continue - - # Check for list[Enum] - is_enum_list, enum_type = is_list_of( - annotation, lambda t: isinstance(t, type) and issubclass(t, Enum) - ) - if is_enum_list: - coercion_type = ( - "unique_enum_list" - if model_class.__name__ - in {"ListCreativeFormatsRequest", "ListCreativeFormatsRequestCreativeAgent"} - and field_name in {"disclosure_positions", "disclosure_persistence"} - else "enum_list" - ) - coercions.append( - { - "field": field_name, - "type": coercion_type, - "target_class": enum_type, - } - ) - continue - - # Check for ContextObject - if base_type is ContextObject: - coercions.append( - { - "field": field_name, - "type": "context", - } - ) - continue - - # Check for ExtensionObject - if base_type is ExtensionObject: - coercions.append( - { - "field": field_name, - "type": "ext", - } - ) - continue - - # Check for list[BaseModel] - for subclass variance - is_model_list, model_type = is_list_of( - annotation, lambda t: isinstance(t, type) and issubclass(t, BaseModel) - ) - if is_model_list and model_type.__name__ in SUBCLASS_LIST_TYPES: - coercions.append( - { - "field": field_name, - "type": "subclass_list", - "target_class": model_type, - } - ) - continue - - return coercions - - -_GENERATED_PREFIX = "adcp.types.domains." - - -def get_import_path(cls) -> str: - """The class's module path relative to the generated domain tree.""" - module = cls.__module__ - # Convert adcp.types.domains.x.y to x.y - return module.removeprefix(_GENERATED_PREFIX) - - -def get_symbol_name(cls) -> str: - """Get the symbol name to use in generated code. - - Some generated modules now expose colliding enum names like Field1 in - multiple namespaces. Give those stable aliases in the generated module. - """ - path = get_import_path(cls) - if path == "creative.list_creatives_request" and cls.__name__ == "Field1": - return "ListCreativesField" - if path == "media_buy.package_request" and cls.__name__ == "Creative": - return "PackageRequestCreative" - if path == "media_buy.package_update" and cls.__name__ == "Creative": - return "PackageUpdateCreative" - return cls.__name__ - - -def generate_code() -> str: - """Generate the _ergonomic.py module content.""" - # Import all the types we need to analyze - from pydantic import BaseModel as _PydBaseModel - - from adcp.types.domains.creative.list_creative_formats_request import ( - ListCreativeFormatsRequestCreativeAgent, - ) - from adcp.types.domains.creative.list_creatives_request import ( - ListCreativesRequest, - Sort, - ) - from adcp.types.domains.creative.list_creatives_response import ListCreativesResponse - from adcp.types.domains.media_buy import ( - create_media_buy_response as _cmbr_module, - ) - from adcp.types.domains.media_buy import ( - update_media_buy_response as _umbr_module, - ) - from adcp.types.domains.media_buy.create_media_buy_request import CreateMediaBuyRequest - from adcp.types.domains.media_buy.get_media_buy_delivery_response import ( - GetMediaBuyDeliveryResponse, - ) - from adcp.types.domains.media_buy.get_products_request import ( - GetProductsRequest, - ) - from adcp.types.domains.media_buy.get_products_response import GetProductsResponse - from adcp.types.domains.media_buy.list_creative_formats_request import ( - ListCreativeFormatsRequest, - ) - from adcp.types.domains.media_buy.list_creative_formats_response import ( - ListCreativeFormatsResponse, - ) - from adcp.types.domains.media_buy.package_request import PackageRequest - from adcp.types.domains.media_buy.package_update import PackageUpdate - - # Resolve success variants. Different - # datamodel-codegen versions emit the variants under shifting names: - # sometimes `FooResponse1`/`...2`, sometimes `FooResponse` - # (success, unnumbered) + `CreateMediaBuyResponseN`. Find the success - # variant by scanning the module for a pydantic model whose fields include - # the success-only `media_buy_id` key. - def _find_media_buy_success_variant(module: Any) -> type[_PydBaseModel] | None: - for name in dir(module): - obj = getattr(module, name) - if ( - isinstance(obj, type) - and issubclass(obj, _PydBaseModel) - and "media_buy_id" in getattr(obj, "model_fields", {}) - ): - return obj - return None - - create_media_buy_response1 = _find_media_buy_success_variant(_cmbr_module) - update_media_buy_response1 = _find_media_buy_success_variant(_umbr_module) - - # Map names to classes - request_classes = { - "ListCreativeFormatsRequest": ListCreativeFormatsRequest, - "ListCreativeFormatsRequestCreativeAgent": ListCreativeFormatsRequestCreativeAgent, - "ListCreativesRequest": ListCreativesRequest, - "PackageRequest": PackageRequest, - "CreateMediaBuyRequest": CreateMediaBuyRequest, - } - - response_classes = { - "GetProductsResponse": GetProductsResponse, - "ListCreativesResponse": ListCreativesResponse, - "ListCreativeFormatsResponse": ListCreativeFormatsResponse, - "GetMediaBuyDeliveryResponse": GetMediaBuyDeliveryResponse, - } - if create_media_buy_response1 is not None: - response_classes["CreateMediaBuyResponse1"] = create_media_buy_response1 - if update_media_buy_response1 is not None: - response_classes["UpdateMediaBuyResponse1"] = update_media_buy_response1 - - nested_classes = { - "Sort": Sort, - "GetProductsRequest": GetProductsRequest, - "PackageUpdate": PackageUpdate, - } - - # Analyze all types - all_coercions = {} - all_imports = set() - - for name, cls in {**request_classes, **response_classes, **nested_classes}.items(): - coercions = analyze_model(cls) - if coercions: - all_coercions[name] = (cls, coercions) - # Collect imports - for c in coercions: - if "target_class" in c: - all_imports.add(c["target_class"]) - - # Group imports by module - enum_imports = [] - core_imports = [] - request_imports = [] - - for cls in all_imports: - path = get_import_path(cls) - if path.startswith("enums."): - enum_imports.append((cls.__name__, path)) - elif path.startswith("core."): - core_imports.append((cls.__name__, path)) - elif path.startswith("media_buy.") or path.startswith("creative."): - request_imports.append((cls.__name__, get_symbol_name(cls), path)) - - # Always include these core types - core_imports.append(("ContextObject", "core.context")) - core_imports.append(("ExtensionObject", "core.ext")) - core_imports.append(("CreativeAssignment", "core.creative_assignment")) - core_imports.append(("Product", "core.product")) - core_imports.append(("Format", "core.format")) - core_imports.append(("Package", "core.package")) - core_imports.append(("Error", "core.error")) - - # Deduplicate - enum_imports = sorted(set(enum_imports)) - core_imports = sorted(set(core_imports)) - - # Build the module - lines = [ - "# AUTO-GENERATED by scripts/generate_ergonomic_coercion.py", - "# Do not edit manually - changes will be overwritten on next type generation.", - "# To regenerate: python scripts/generate_types.py", - "", - "# ruff: noqa: E501, I001", - '"""Apply type coercion to generated types for better ergonomics.', - "", - "This module patches the generated types to accept more flexible input types", - "while maintaining type safety. It uses Pydantic's model_rebuild() to add", - "BeforeValidator annotations to fields.", - "", - "Why import-time patching?", - " We apply coercion at module load time rather than lazily because:", - " 1. Pydantic validation runs during __init__, before any lazy access", - " 2. model_rebuild() is the standard Pydantic pattern for post-hoc changes", - " 3. The cost is minimal (~10-20ms for all types, once at import)", - " 4. After import, there is zero runtime overhead", - " 5. This approach maintains full type checker compatibility", - "", - "Coercion rules applied:", - '1. Enum fields accept string values (e.g., "video" for enum fields)', - '2. List[Enum] fields accept list of strings (e.g., ["image", "video"])', - "3. ContextObject fields accept dict values", - "4. ExtensionObject fields accept dict values", - "5. FieldModel (enum) lists accept string lists", - "", - "Note: List variance issues (list[Subclass] not assignable to list[BaseClass])", - "are a fundamental Python typing limitation. Response-only container fields", - "(affected_packages, media_buys, packages, media_buy_deliveries) already use", - "Sequence[T] in their generated base class. For other fields not yet migrated,", - "adopters should use Sequence[T] in their own code or cast() for appeasement.", - '"""', - "", - "from __future__ import annotations", - "", - "from collections.abc import Sequence", - "from typing import Annotated, Any", - "", - "from pydantic import BeforeValidator, WrapValidator", - "", - "from adcp.types.coercion import (", - " coerce_subclass_list,", - " coerce_to_enum,", - " coerce_to_enum_list,", - " coerce_to_unique_enum_list,", - " coerce_to_model,", - ")", - "", - ] - - # Add core imports - for name, path in core_imports: - lines.append(f"from adcp.types.domains.{path} import {name}") - - # Add enum imports - for name, path in enum_imports: - lines.append(f"from adcp.types.domains.{path} import {name}") - - # Add request type imports - if "CreateMediaBuyRequest" in all_coercions: - lines.append("from adcp.types.domains.media_buy.create_media_buy_request import (") - lines.append(" CreateMediaBuyRequest,") - lines.append(")") - lines.append("from adcp.types.domains.media_buy.get_products_request import (") - lines.append(" GetProductsRequest,") - lines.append(")") - lines.append("from adcp.types.domains.media_buy.list_creative_formats_request import (") - lines.append(" ListCreativeFormatsRequest,") - lines.append(")") - lines.append("from adcp.types.domains.creative.list_creative_formats_request import (") - lines.append(" ListCreativeFormatsRequestCreativeAgent,") - lines.append(")") - lines.append("from adcp.types.domains.creative.list_creatives_request import (") - lines.append(" Field1 as ListCreativesField,") - lines.append(" ListCreativesRequest,") - lines.append(" Sort,") - lines.append(")") - lines.append("from adcp.types.domains.media_buy.package_request import PackageRequest") - lines.append("from adcp.types.domains.media_buy.package_update import PackageUpdate") - - # Add response type imports. CreateMediaBuyResponse1's numeric suffix can - # shift between codegen versions; in schema versions that collapse the - # response envelope, there may be no success-specific response variant. - if create_media_buy_response1 is not None: - cmbr_name = create_media_buy_response1.__name__ - lines.append("from adcp.types.domains.media_buy.create_media_buy_response import (") - if cmbr_name == "CreateMediaBuyResponse1": - lines.append(" CreateMediaBuyResponse1,") - else: - lines.append(f" {cmbr_name} as CreateMediaBuyResponse1,") - lines.append(")") - if update_media_buy_response1 is not None: - umbr_name = update_media_buy_response1.__name__ - lines.append("from adcp.types.domains.media_buy.update_media_buy_response import (") - if umbr_name == "UpdateMediaBuyResponse1": - lines.append(" UpdateMediaBuyResponse1,") - else: - lines.append(f" {umbr_name} as UpdateMediaBuyResponse1,") - lines.append(")") - lines.append("from adcp.types.domains.media_buy.get_media_buy_delivery_response import (") - lines.append(" GetMediaBuyDeliveryResponse,") - lines.append(" MediaBuyDelivery,") - lines.append(" NotificationType,") - lines.append(")") - lines.append( - "from adcp.types.domains.media_buy.get_products_response import GetProductsResponse" - ) - lines.append("from adcp.types.domains.media_buy.list_creative_formats_response import (") - lines.append(" CreativeAgent,") - lines.append(" ListCreativeFormatsResponse,") - lines.append(")") - lines.append("from adcp.types.domains.creative.list_creatives_response import (") - lines.append(" ListCreativesResponse,") - lines.append(")") - - # Add any discovered enum/model imports from request/response modules - # These are types (like BuyingMode, ValidAction) defined inside request/response - # modules rather than in the enums/ directory. - # Filter out types already imported in the hardcoded block above. - already_imported = { - "CreateMediaBuyRequest", - "GetProductsRequest", - "Field1", - "ListCreativeFormatsRequest", - "ListCreativesRequest", - "Sort", - "PackageRequest", - "PackageUpdate", - "CreateMediaBuyResponse1", - "UpdateMediaBuyResponse1", - "GetMediaBuyDeliveryResponse", - "MediaBuyDelivery", - "NotificationType", - "GetProductsResponse", - "ListCreativeFormatsResponse", - "CreativeAgent", - "ListCreativesResponse", - } - request_imports_sorted = sorted(set(request_imports)) - for name, symbol_name, path in request_imports_sorted: - if name not in already_imported: - imported_name = name if name == symbol_name else f"{name} as {symbol_name}" - lines.append(f"from adcp.types.domains.{path} import {imported_name}") - - lines.append("") - lines.append("") - lines.append("def _apply_coercion() -> None:") - lines.append(' """Apply coercion validators to generated types.') - lines.append("") - lines.append(" This function modifies the generated types in-place to accept") - lines.append(" more flexible input types.") - lines.append(' """') - - # Generate coercion code for each type - # Process in a specific order for readability - type_order = [ - # Request types - "ListCreativeFormatsRequest", - "ListCreativeFormatsRequestCreativeAgent", - "ListCreativesRequest", - "Sort", - "GetProductsRequest", - "PackageRequest", - "CreateMediaBuyRequest", - "PackageUpdate", - # Response types - "GetProductsResponse", - "ListCreativesResponse", - "ListCreativeFormatsResponse", - "CreateMediaBuyResponse1", - "UpdateMediaBuyResponse1", - "GetMediaBuyDeliveryResponse", - ] - - for type_name in type_order: - if type_name not in all_coercions: - continue - - cls, coercions = all_coercions[type_name] - - # Add comment describing what we're coercing - field_comments = [] - for c in coercions: - if c["type"] == "enum": - target = get_symbol_name(c["target_class"]) - field_comments.append(f'{c["field"]}: {target} | str | None') - elif c["type"] in {"enum_list", "unique_enum_list"}: - target = get_symbol_name(c["target_class"]) - field_comments.append(f'{c["field"]}: list[{target} | str] | None') - elif c["type"] == "context": - field_comments.append(f'{c["field"]}: ContextObject | dict | None') - elif c["type"] == "ext": - field_comments.append(f'{c["field"]}: ExtensionObject | dict | None') - elif c["type"] == "subclass_list": - from collections.abc import Sequence as AbcSequence - - field_info = cls.model_fields[c["field"]] - ann = field_info.annotation - base_ann = get_base_type(ann) - is_seq = get_origin(base_ann if base_ann is not None else ann) is AbcSequence - container = "Sequence" if is_seq else "list" - field_comments.append( - f'{c["field"]}: {container}[{get_symbol_name(c["target_class"])}] ' - "(accepts subclass instances)" - ) - - lines.append(f" # Apply coercion to {type_name}") - for comment in field_comments: - lines.append(f" # - {comment}") - - # Generate the actual coercion code - for c in coercions: - field = c["field"] - if c["type"] == "enum": - target = get_symbol_name(c["target_class"]) - lines.append(" _patch_field_annotation(") - lines.append(f" {type_name},") - lines.append(f' "{field}",') - annotation = f"{target} | None, BeforeValidator(coerce_to_enum({target}))" - line = f" Annotated[{annotation}]," - # Match the repository's 100-column generated source contract. - # Otherwise normal pinned formatting changes this output after - # generation and leaves a reproducible strict-check drift. - if len(line) > 100: - lines.extend([" Annotated[", f" {annotation}", " ],"]) - else: - lines.append(line) - lines.append(" )") - elif c["type"] == "unique_enum_list": - target = get_symbol_name(c["target_class"]) - lines.append(" _patch_unique_enum_list(") - lines.append(f" {type_name},") - lines.append(f' "{field}",') - lines.append(f" {target},") - lines.append(" )") - elif c["type"] == "enum_list": - target = get_symbol_name(c["target_class"]) - lines.append(" _patch_field_annotation(") - lines.append(f" {type_name},") - lines.append(f' "{field}",') - lines.append(" Annotated[") - lines.append(f" list[{target}] | None,") - lines.append(f" BeforeValidator(coerce_to_enum_list({target})),") - lines.append(" ],") - lines.append(" )") - elif c["type"] == "context": - lines.append(" _patch_field_annotation(") - lines.append(f" {type_name},") - lines.append(f' "{field}",') - lines.append( - " Annotated[ContextObject | None, " - "BeforeValidator(coerce_to_model(ContextObject))]," - ) - lines.append(" )") - elif c["type"] == "ext": - lines.append(" _patch_field_annotation(") - lines.append(f" {type_name},") - lines.append(f' "{field}",') - lines.append( - " Annotated[ExtensionObject | None, " - "BeforeValidator(coerce_to_model(ExtensionObject))]," - ) - lines.append(" )") - elif c["type"] == "subclass_list": - from collections.abc import Sequence as AbcSequence - - target = get_symbol_name(c["target_class"]) - field_info = cls.model_fields[field] - is_optional = "None" in str(field_info.annotation) - # Preserve Sequence[T] when the field already uses it (covariant - # inheritance, set by post_generate_fixes.rewrite_response_list_to_sequence). - ann = field_info.annotation - base_ann = get_base_type(ann) - is_seq = get_origin(base_ann if base_ann is not None else ann) is AbcSequence - container = "Sequence" if is_seq else "list" - type_str = ( - f"{container}[{target}] | None" if is_optional else f"{container}[{target}]" - ) - lines.append(" _patch_field_annotation(") - lines.append(f" {type_name},") - lines.append(f' "{field}",') - lines.append(" Annotated[") - lines.append(f" {type_str},") - lines.append(f" BeforeValidator(coerce_subclass_list({target})),") - lines.append(" ],") - lines.append(" )") - - lines.append(f" {type_name}.model_rebuild(force=True)") - lines.append("") - - # PackageUpdate is now a single class (flattened from validation-only oneOf) - - # Add helper functions - lines.append("") - lines.append("def _patch_unique_enum_list(") - lines.append(" model: Any,") - lines.append(" field_name: str,") - lines.append(" enum_class: type,") - lines.append(") -> None:") - lines.append(' """Add an order-preserving unique-items enum validator."""') - lines.append(" model.model_fields[field_name].metadata.append(") - lines.append(" WrapValidator(coerce_to_unique_enum_list(enum_class))") - lines.append(" )") - lines.append("") - lines.append("") - lines.append("def _patch_field_annotation(") - lines.append(" model: type,") - lines.append(" field_name: str,") - lines.append(" new_annotation: Any,") - lines.append(") -> None:") - lines.append(' """Patch a field annotation on a Pydantic model.') - lines.append("") - lines.append(" This modifies the model's __annotations__ dict to add") - lines.append(" BeforeValidator coercion.") - lines.append(' """') - lines.append(" model.__annotations__[field_name] = new_annotation") - lines.append("") - lines.append("") - lines.append("# Apply coercion when module is imported") - lines.append("_apply_coercion()") - lines.append("") - - return "\n".join(lines) - - -def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--source-root", - type=Path, - default=REPO_ROOT / "src", - help="source root containing the adcp package to introspect", - ) - parser.add_argument( - "--output-file", - type=Path, - default=OUTPUT_FILE, - help="destination for the generated ergonomic module", - ) - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None): - """Generate _ergonomic.py from model introspection.""" - global OUTPUT_FILE - - args = _parse_args(argv) - source_root = args.source_root.resolve() - OUTPUT_FILE = args.output_file.resolve() - sys.path[0] = str(source_root) - print("Generating ergonomic coercion module...") - - content = generate_code() - - # Write to output file - OUTPUT_FILE.write_text(content) - print(f" ✓ Generated {OUTPUT_FILE}") - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/generate_types.py b/scripts/generate_types.py index ba53c3730..b93abd263 100755 --- a/scripts/generate_types.py +++ b/scripts/generate_types.py @@ -168,6 +168,132 @@ def _normalize_schema_ref_target( #: with their schema. OPEN_VOCABULARY_SCHEMAS = {Path("core/canonical-format-kind.json")} +#: Fields the implementation needs that the PINNED schema does not declare. +#: +#: A pin gap is not a defect in the generated tree and not a strictness +#: decision: it is a field a cooperating seller and buyer have agreed on, whose +#: schema change is still upstream. The SDK has to carry it, and WHERE it +#: carries it decides whether it works. +#: +#: Declared here, as a SCHEMA transform, for the same reason +#: ``OPEN_VOCABULARY_SCHEMAS`` is: the generator then emits the field like any +#: other, with its constraints and its description, and every generated class +#: that nests the owning schema gets it by generation rather than by a patch. +#: That is the whole difference. Patching the class after generation reaches +#: only the class, and ``core/reporting-webhook.json`` is nested by five +#: request schemas that each name the generated type -- so a parsed request +#: produced a webhook without the field, and a hand-written subclass carrying +#: it was reachable only by a caller who named it. +#: +#: The upstream issue is cited in the field's own ``description``, so the +#: citation reaches the GENERATED OUTPUT and a reader of the model sees why the +#: field is there. +#: +#: The sunset is :func:`add_pin_gap_fields`'s own refusal below: it raises when +#: the property already exists, so the first regeneration after upstream +#: declares the field fails here. It is NOT a ``DECLARED`` entry in +#: ``tests/conformance/_schema_parity.py``, which an earlier version of this +#: comment claimed. That suite mutates the leaves of a synthesized payload, so +#: it never observes a property the model declares and the schema does not -- +#: and a ``DECLARED`` predicate matching nothing is exactly what +#: ``test_schema_model_parity.py`` fails on, so adding one would break the +#: suite rather than guard the gap. +PIN_GAP_FIELDS: dict[Path, tuple[dict[str, Any], ...]] = { + # The bundled tree is an independently generated view of the same wire + # schema: it INLINES what the canonical tree reaches by ``$ref``, so the + # same gap has to be declared at the inlined position too. Both entries name + # the same field for the same reason, and the DECLARED entry covers both. + Path("bundled/protocol/get-adcp-capabilities-response.json"): ( + { + "pointer": "properties/media_buy/properties/features", + "name": "canonical_creatives", + "schema": { + "type": "boolean", + "description": ( + "Advertises canonical creative identity on AdCP 3.1. AdCP 3.2+ is " + "canonical by contract. Published after the bundled capabilities " + "schema; negotiation must not silently discard this evidence." + ), + }, + }, + ), + Path("core/media-buy-features.json"): ( + { + "pointer": "", + "name": "canonical_creatives", + "schema": { + "type": "boolean", + "description": ( + "Advertises canonical creative identity on AdCP 3.1. AdCP 3.2+ is " + "canonical by contract. Published after the bundled capabilities " + "schema; negotiation must not silently discard this evidence." + ), + }, + }, + ), + Path("core/reporting-webhook.json"): ( + { + "pointer": "", + "name": "operation_id", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$", + "description": ( + "Opt-in AdCP 3.2 reporting-stream correlation extension pending " + "adcp#7885. Cooperating sellers store and echo the buyer-supplied " + "value verbatim in media_buy_delivery webhook payloads. Confirm " + "seller support before use; registrations without a value cannot " + "enable this interim delivery path." + ), + }, + }, + ), +} + + +def add_pin_gap_fields(schema: Any, current_schema_rel_path: Path) -> Any: + """Declare each :data:`PIN_GAP_FIELDS` entry on the schema that owns it. + + Additive and position-addressed: the field is written into the ``properties`` + of the node the entry's pointer names, and never into ``required`` -- a gap + field is opt-in by construction, because a seller that has not adopted it + cannot be sent one. + + Raises if the pointer does not resolve or the property already exists. The + second is the loud half: when upstream declares the field, this entry stops + being a gap and the run fails here rather than silently shadowing a schema + that now disagrees with it. + """ + entries = PIN_GAP_FIELDS.get(current_schema_rel_path) + if not entries: + return schema + for entry in entries: + node = schema + for segment in [part for part in entry["pointer"].split("/") if part]: + if not isinstance(node, dict) or segment not in node: + raise RuntimeError( + f"{current_schema_rel_path}: pin-gap pointer {entry['pointer']!r} " + f"does not resolve (stopped at {segment!r})" + ) + node = node[segment] + if not isinstance(node, dict): + raise RuntimeError( + f"{current_schema_rel_path}: pin-gap pointer {entry['pointer']!r} " + "names a non-object node" + ) + properties = node.setdefault("properties", {}) + if entry["name"] in properties: + raise RuntimeError( + f"{current_schema_rel_path}: {entry['name']!r} is declared by the schema " + "now, so it is no longer a pin gap. Delete its PIN_GAP_FIELDS entry and " + "its DECLARED entry in tests/conformance/_schema_parity.py." + ) + properties[entry["name"]] = copy.deepcopy(entry["schema"]) + return schema + + #: Keys worth keeping when a reference is opened: they document the field and #: carry no validation, so keeping them leaves the generated annotation's #: description intact. @@ -252,7 +378,7 @@ def normalize_enum_descriptions(obj): # subschema, and datamodel-code-generator emits one class for it and shares it. # Every other pointer addresses a structural position — ``/properties/<name>``, # ``/items``, ``/oneOf/<n>`` — which has no name to emit, so the generator mints -# a single-arm ``RootModel`` wrapper per referencing site instead of the type the +# a single-branch ``RootModel`` wrapper per referencing site instead of the type the # pointer selects. _DEFINITION_POINTER_ROOTS = frozenset({"$defs", "definitions"}) @@ -321,7 +447,7 @@ def _names_a_class(selected: dict) -> bool: The structural-pointer defect is specific to a selection that has *no* class of its own — an array, a constrained scalar, a bare ``$ref`` — where - the generator mints a single-arm ``RootModel`` wrapper per referencing site. + the generator mints a single-branch ``RootModel`` wrapper per referencing site. A selection that is an object, an enumeration or a composition generates a named class in the module that owns it, and the generator resolves a pointer to it as that shared class: ``targeting.json#/properties/age_restriction`` @@ -391,7 +517,7 @@ def inline_structural_pointer_refs(obj, current_schema_rel_path: Path, _seen=fro itself, which with ``collapse_nullable_unions`` becomes ``list[str] | None`` — the type the pointed-to property gets in its own module. - A pointer that is the only arm of an ``allOf`` merges into the enclosing + A pointer that is the only branch of an ``allOf`` merges into the enclosing schema. ``core/catalog-selection.json`` adds ``uniqueItems`` that way and ``compliance/comply-test-controller-request.json`` adds a ``description``; leaving the selection inside the ``allOf`` loses the ``additionalProperties`` @@ -406,11 +532,13 @@ def inline_structural_pointer_refs(obj, current_schema_rel_path: Path, _seen=fro # fresh ``_seen`` would re-enter a cyclic pointer. return _merge_selection(obj, selected, "$ref") - arms = obj.get("allOf") - if isinstance(arms, list) and len(arms) == 1 and isinstance(arms[0], dict): - only_arm_ref = arms[0].get("$ref") - if isinstance(only_arm_ref, str) and len(arms[0]) == 1: - selected = _select_structural_pointer(only_arm_ref, current_schema_rel_path, _seen) + branches = obj.get("allOf") + if isinstance(branches, list) and len(branches) == 1 and isinstance(branches[0], dict): + only_branch_ref = branches[0].get("$ref") + if isinstance(only_branch_ref, str) and len(branches[0]) == 1: + selected = _select_structural_pointer( + only_branch_ref, current_schema_rel_path, _seen + ) if selected is not None: return _merge_selection(obj, selected, "allOf") @@ -426,16 +554,16 @@ def inline_structural_pointer_refs(obj, current_schema_rel_path: Path, _seen=fro _NULL_ARM = {"type": "null"} -def _as_nullable_arm(arms: list) -> dict | None: - """Return the non-null arm of a two-arm "S or null" union, when typed. +def _as_nullable_branch(branches: list) -> dict | None: + """Return the non-null branch of a two-branch "S or null" union, when typed. - Only an arm that carries a ``type`` keyword qualifies. An arm that is a - ``$ref`` already produces ``T | None``, and an arm with no ``type`` has + Only an branch that carries a ``type`` keyword qualifies. An branch that is a + ``$ref`` already produces ``T | None``, and an branch with no ``type`` has nothing to make nullable. """ - if len(arms) != 2 or arms.count(_NULL_ARM) != 1: + if len(branches) != 2 or branches.count(_NULL_ARM) != 1: return None - other = arms[0] if arms[1] == _NULL_ARM else arms[1] + other = branches[0] if branches[1] == _NULL_ARM else branches[1] if not isinstance(other, dict) or "$ref" in other: return None declared = other.get("type") @@ -447,9 +575,9 @@ def _as_nullable_arm(arms: list) -> dict | None: def collapse_nullable_unions(obj): - """Spell "S or null" as a nullable ``S`` rather than a two-arm union. + """Spell "S or null" as a nullable ``S`` rather than a two-branch union. - datamodel-code-generator emits a named ``RootModel`` for an ``anyOf`` arm + datamodel-code-generator emits a named ``RootModel`` for an ``anyOf`` branch that declares a container or scalar ``type``, then types the field with the wrapper:: @@ -484,7 +612,7 @@ class AudienceExclude(RootModel[list[str]]): ... if not isinstance(schema, dict): continue for keyword in ("anyOf", "oneOf"): - collapsed = _as_nullable_arm(schema.get(keyword) or []) + collapsed = _as_nullable_branch(schema.get(keyword) or []) if collapsed is not None: siblings = {k: v for k, v in schema.items() if k != keyword} schema.clear() @@ -565,7 +693,7 @@ def rewrite_refs(obj, current_schema_rel_path: Path): # the problem is a build failure rather than a silent behavior change. Measured at pin # 3.2.1: 8 pointer-ref task requests and 4 wire-equivalent inline declarers (1 task # request, 3 para-protocol). The 2 pointer-ref task RESPONSES are refused by -# ``_has_bare_ref_root_arm`` -- rewriting them deletes a field from their submitted arm. +# ``_has_bare_ref_root_branch`` -- rewriting them deletes a field from their submitted branch. _VERSION_ENVELOPE_NORMALIZED_CEILING = 12 @@ -598,13 +726,13 @@ def _without_documentation(node: Any) -> Any: def _composes_version_envelope(schema: dict) -> bool: """True when the schema already pulls the envelope in through a root ``allOf``.""" - arms = schema.get("allOf") - if not isinstance(arms, list): + branches = schema.get("allOf") + if not isinstance(branches, list): return False - for arm in arms: - if not isinstance(arm, dict): + for branch in branches: + if not isinstance(branch, dict): continue - ref = arm.get("$ref") + ref = branch.get("$ref") if ( isinstance(ref, str) and "#" not in ref @@ -614,27 +742,27 @@ def _composes_version_envelope(schema: dict) -> bool: return False -def _has_bare_ref_root_arm(schema: dict) -> bool: - """True when a root ``anyOf``/``oneOf`` arm is a bare ``$ref`` to another schema. +def _has_bare_ref_root_branch(schema: dict) -> bool: + """True when a root ``anyOf``/``oneOf`` branch is a bare ``$ref`` to another schema. This is the one case where the rewrite would REMOVE a field instead of relocating it. - datamodel-code-generator merges the root ``properties`` into every arm it builds, but a - root ``allOf`` ``$ref`` becomes a base only of the arm it builds FROM the root object -- - an arm that is a ``$ref`` elsewhere is generated from the referenced schema and the - root's bases never reach it. So dropping the stubs strips the field from that arm and + datamodel-code-generator merges the root ``properties`` into every branch it builds, but a + root ``allOf`` ``$ref`` becomes a base only of the branch it builds FROM the root object -- + an branch that is a ``$ref`` elsewhere is generated from the referenced schema and the + root's bases never reach it. So dropping the stubs strips the field from that branch and the composition does not give it back. Measured: applying the rewrite anyway cost ``RefineProposalsResponse2`` and ``RequestProposalsResponse4`` their ``adcp_version`` outright (10 fields to 9, 16 to 15), - and both carry ``extra: forbid``, so a buyer echoing the version on the submitted arm - would have been REJECTED where it previously validated. Those two arms are + and both carry ``extra: forbid``, so a buyer echoing the version on the submitted branch + would have been REJECTED where it previously validated. Those two branches are ``core/compact-task-submitted.json``, which composes no envelope of its own. """ for key in ("anyOf", "oneOf"): - arms = schema.get(key) - if not isinstance(arms, list): + branches = schema.get(key) + if not isinstance(branches, list): continue - if any(isinstance(arm, dict) and "$ref" in arm for arm in arms): + if any(isinstance(branch, dict) and "$ref" in branch for branch in branches): return True return False @@ -707,8 +835,8 @@ def normalize_version_envelope_composition(schema: dict, schema_rel_path: Path) } if not declared or _composes_version_envelope(schema): return schema - if _has_bare_ref_root_arm(schema): - # Relocating the stubs here would DELETE the field from the ``$ref`` arm. Refuse, + if _has_bare_ref_root_branch(schema): + # Relocating the stubs here would DELETE the field from the ``$ref`` branch. Refuse, # and let the schema surface in the hierarchy ratchet's residue by name. return schema @@ -730,10 +858,10 @@ def normalize_version_envelope_composition(schema: dict, schema_rel_path: Path) del properties[name] if not properties: del schema["properties"] - arms = schema.setdefault("allOf", []) - if not isinstance(arms, list): + branches = schema.setdefault("allOf", []) + if not isinstance(branches, list): raise ValueError(f"{schema_rel_path.as_posix()}: root allOf is not a list") - arms.append({"$ref": _version_envelope_ref()}) + branches.append({"$ref": _version_envelope_ref()}) global _VERSION_ENVELOPE_NORMALIZED _VERSION_ENVELOPE_NORMALIZED += 1 @@ -1033,6 +1161,7 @@ def flatten_schemas(temp_dir: Path): # ``_normalize_schema_ref_target`` resolves. See OPEN_VOCABULARY_SCHEMAS # for why one enum is deliberately not reproduced. schema = open_vocabulary_refs(schema, rel_path) + schema = add_pin_gap_fields(schema, rel_path) schema = rewrite_refs(schema, rel_path) schema = stabilize_inlined_core_refs(schema, rel_path) schema = stabilize_nested_discriminators(schema, rel_path) @@ -1411,14 +1540,6 @@ def _staged_source_tree(staging_root: Path) -> Path: if staged_generated.exists(): shutil.rmtree(staged_generated) - # The committed ergonomic module can import generated names that no longer - # exist. The isolated import graph uses this inert stub until a fresh module - # is generated from the staged models. - (staged_package / "types" / "_ergonomic.py").write_text( - '"""Temporary ergonomic stub used during type generation."""\n\n' - "def apply_ergonomic_coercion() -> None:\n" - " pass\n" - ) return staged_source @@ -1511,7 +1632,6 @@ def main(argv: list[str] | None = None): staged_types = staged_source / "adcp" / "types" staged_output = staged_types / OUTPUT_DIR.name staged_consolidated = staged_types / "_generated.py" - staged_ergonomic = staged_types / "_ergonomic.py" staged_error_details = staged_types / "error_details.py" staged_report = staging_root / "shared-type-names.md" @@ -1549,32 +1669,8 @@ def main(argv: list[str] | None = None): if result.stdout: print(result.stdout, end="") - ergonomic_script = REPO_ROOT / "scripts" / "generate_ergonomic_coercion.py" - if ergonomic_script.exists(): - print("\nGenerating ergonomic coercion module...") - result = subprocess.run( - [ - sys.executable, - str(ergonomic_script), - "--source-root", - str(staged_source), - "--output-file", - str(staged_ergonomic), - ], - capture_output=True, - text=True, - cwd=staging_root, - ) - if result.returncode != 0: - print("\n✗ Ergonomic coercion generation failed:", file=sys.stderr) - print(result.stderr, file=sys.stderr) - return 1 - if result.stdout: - print(result.stdout, end="") - current_types = REPO_ROOT / "src" / "adcp" / "types" restore_unchanged_file(staged_consolidated, current_types / "_generated.py") - restore_unchanged_file(staged_ergonomic, current_types / "_ergonomic.py") restore_unchanged_file(staged_error_details, current_types / "error_details.py") restore_unchanged_file(staged_report, SHARED_TYPE_NAMES_FILE) restore_unchanged_files(staged_output) @@ -1582,7 +1678,6 @@ def main(argv: list[str] | None = None): artifacts = [ (staged_output, OUTPUT_DIR), (staged_consolidated, current_types / "_generated.py"), - (staged_ergonomic, current_types / "_ergonomic.py"), (staged_error_details, current_types / "error_details.py"), (staged_report, SHARED_TYPE_NAMES_FILE), ] diff --git a/scripts/generated_names.py b/scripts/generated_names.py new file mode 100644 index 000000000..0173bf710 --- /dev/null +++ b/scripts/generated_names.py @@ -0,0 +1,67 @@ +"""The class names ``datamodel-code-generator`` assigns, asked rather than restated. + +The generator names a schema ROOT and a named NESTED object by two DIFFERENT +rules, and this repo restated both rather than calling either. Three +restatements existed: + +* ``post_generate_fixes._schema_title_to_class_name`` — a faithful copy of the + NESTED rule, used for the root as well. Wrong on every acronym for 94 of the + 946 roots where the generated module declares a class to compare against + (salesagent-5pe83); +* ``task_message_lattice.mangle_schema_title`` — a ``str.capitalize`` copy of the + ROOT rule. Wrong for 5 of the same 946: ``A2UI Component`` lower-cases a + digit-split token to ``A2ui`` where the generator gives ``A2Ui``, and a title + that is ALREADY a Python identifier is flattened rather than kept + (salesagent-8qela.20); +* one more that is not a restatement of a generator fact and stays put: + ``union_branch_names.pascal_case``, which names a union BRANCH — a name this + repo's own emitter chooses, not one the generator assigns. + +Both rules now live here, each a call into the generator that owns the answer, +so there is one representation per fact and a generator that changes its mind +changes these with it. ``datamodel-code-generator`` is pinned exactly (0.64.0 +in both the dev extra and the dev group), so a rule change arrives with a +deliberate bump, and ``tests/test_generated_hierarchy.py``'s +``test_the_root_class_name_derived_from_a_title_is_the_name_the_module_declares`` +grades the derivation against the generated tree on every run. + +This module has no repo-local imports on purpose: ``post_generate_fixes`` +imports ``task_message_lattice``, so a shared helper in either of them would be +a cycle. +""" + +from __future__ import annotations + +from datamodel_code_generator.parser.base import title_to_class_name +from datamodel_code_generator.reference import ModelResolver + + +def generated_nested_class_name(title: object) -> str: + """The class name the generator gives a named NESTED schema object. + + ``ModelResolver.default_class_name_generator`` resolves an upper-camel name + and PRESERVES an acronym: ``"VAST Asset"`` is ``VASTAsset``. The restatement + this replaced — ``"".join(word[:1].upper() + word[1:] for word in words)`` — + was a faithful copy of this rule, and was correct wherever it was used for a + nested object. Its defect was being used for the root as well. + """ + return ModelResolver().default_class_name_generator(str(title)) + + +def generated_root_class_name(title: object) -> str: + """The class name the generator gives a schema ROOT. + + A root goes through one step a nested object does not: + ``JsonSchemaParser._resolve_root_model_name`` title-cases a title that is not + already a Python identifier, so ``"Get AdCP Capabilities Response"`` becomes + ``GetAdcpCapabilitiesResponse`` and not ``GetAdCPCapabilitiesResponse``. A + title that IS already an identifier is kept verbatim — + ``GetCreativeFeaturesSuccess`` is one, and title-casing it unconditionally + yields ``Getcreativefeaturessuccess``. + + Both steps are the generator's own, called in the generator's order. + """ + name = str(title) + if not ModelResolver.validate_name(name): + name = title_to_class_name(name) + return generated_nested_class_name(name) diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 801c10d18..54b519f22 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -28,11 +28,20 @@ import json import re import sys +import textwrap from collections.abc import Mapping, Sequence from copy import deepcopy from pathlib import Path from typing import Any, NamedTuple +try: + from generated_names import generated_nested_class_name, generated_root_class_name +except ModuleNotFoundError: # Imported as ``scripts.post_generate_fixes`` in tests. + from scripts.generated_names import ( # type: ignore[assignment] + generated_nested_class_name, + generated_root_class_name, + ) + try: import consolidate_exports except ModuleNotFoundError: # Imported as ``scripts.post_generate_fixes`` in tests. @@ -43,6 +52,63 @@ except ModuleNotFoundError: # Imported as ``scripts.post_generate_fixes`` in tests. from scripts import task_message_lattice +try: + from branch_classes import ( + BranchPlan, + plan_branches, + render_branch_models, + render_root_required, + repoint_subclasses_of, + split_into_branch_classes, + ) + from compile_conditions import ( + COMBINATOR_FIELDS, + CONDITIONS_IMPORT, + ROOT_CONSTRAINT_VALIDATOR, + compile_condition, + render_root_validator, + schema_keyword_raise, + ) + from schema_constraints import ( + DeclaredShape, + RootConstraints, + bundle_document_resolver, + partitions_by_requiredness, + permits_null, + read_root_constraints, + root_carries_constraints, + root_combinator, + root_required_names, + ) +except ModuleNotFoundError: # Imported as ``scripts.post_generate_fixes`` in tests. + from scripts.branch_classes import ( # type: ignore[assignment] + BranchPlan, + plan_branches, + render_branch_models, + render_root_required, + repoint_subclasses_of, + split_into_branch_classes, + ) + from scripts.compile_conditions import ( # type: ignore[assignment] + COMBINATOR_FIELDS, + CONDITIONS_IMPORT, + ROOT_CONSTRAINT_VALIDATOR, + compile_condition, + render_root_validator, + schema_keyword_raise, + ) + from scripts.schema_constraints import ( # type: ignore[assignment] + DeclaredShape, + RootConstraints, + bundle_document_resolver, + partitions_by_requiredness, + permits_null, + read_root_constraints, + root_carries_constraints, + root_combinator, + root_required_names, + ) + REPO_ROOT = Path(__file__).parent.parent @@ -180,66 +246,6 @@ def ensure_pydantic_core_import(source: str, *names: str) -> str: return source -#: The JSON Schema keywords an injected runtime check may name. -#: -#: ``core/error.json`` requires every ``issues[]`` entry to carry a ``keyword`` -#: "drawn from the JSON Schema vocabulary" and says not to invent values there, -#: so a check names the keyword of the construct it restores and nothing else. -#: Membership is checked rather than documented because an invented keyword -#: reaches the wire looking exactly like a real one. -SCHEMA_KEYWORDS = frozenset( - { - "allOf", - "anyOf", - "const", - "contains", - "dependentRequired", - "else", - "enum", - "format", - "if", - "maxItems", - "maxLength", - "maximum", - "minItems", - "minLength", - "minimum", - "not", - "oneOf", - "pattern", - "propertyNames", - "required", - "then", - "type", - "uniqueItems", - } -) - - -def schema_keyword_raise(keyword: str, message: str, *, indent: str = " " * 8) -> str: - """Render the ``raise`` statement an injected runtime check refuses with. - - ``PydanticCustomError`` sets pydantic's error ``type`` to its first argument - and keeps the message verbatim, so ``e.errors()[0]["type"]`` is the schema - keyword that rejected the payload — which is what a consumer needs for - ``error.issues[].keyword``. A bare ``ValueError`` becomes ``value_error``, - which names no keyword, and a consumer assembling a conformant envelope then - has to drop the issue and refuse the buyer with no structured reason. - - The caller passes the keyword the SCHEMA states, never one read off the - message: the English is written for a human and two different keywords can - share a phrasing. - """ - if keyword not in SCHEMA_KEYWORDS: - raise ValueError(f"{keyword!r} is not a JSON Schema keyword an issue may name") - return ( - f"{indent}raise PydanticCustomError(\n" - f"{indent} {keyword!r},\n" - f"{indent} {message!r},\n" - f"{indent})" - ) - - _VERSION_FILE = REPO_ROOT / "src" / "adcp" / "ADCP_VERSION" _BUNDLE_KEY = resolve_bundle_key(_VERSION_FILE.read_text().strip()) @@ -248,9 +254,6 @@ def schema_keyword_raise(keyword: str, message: str, *, indent: str = " " * 8) - _PROTOCOL_ENVELOPE_IMPORT = "from ..core.protocol_envelope import ProtocolEnvelope\n" _VERSION_ENVELOPE_IMPORT = "from ..core.version_envelope import AdcpVersionEnvelope\n" -_RESPONSE_ARM_DISPATCH_IMPORT = ( - "from adcp.types.response_dispatch import ResponseArmDispatchMixin\n" -) _STR_ENUM_MEMBER_ASSIGNMENT_IGNORE = " # type: ignore[assignment]" _STR_ATTRIBUTE_NAMES = set(dir(str)) @@ -270,7 +273,7 @@ def _resolve_schema_ref(schema_rel: Path, ref: str) -> Path: def _sync_protocol_envelope_import(source: str) -> str: - """Keep the ProtocolEnvelope import aligned with restored response arms.""" + """Keep the ProtocolEnvelope import aligned with restored response branches.""" uses_protocol_envelope = "ProtocolEnvelope" in source.replace(_PROTOCOL_ENVELOPE_IMPORT, "") if not uses_protocol_envelope: return source.replace(_PROTOCOL_ENVELOPE_IMPORT, "") @@ -724,9 +727,9 @@ def walk(obj: object, path: tuple[str, ...]) -> None: if obj.get("x-adcp-open-payload") is True: title = obj.get("title") if path == (): - class_names.append(_schema_title_to_class_name(title) if title else None) + class_names.append(generated_root_class_name(title) if title else None) elif isinstance(title, str) and title.strip(): - class_names.append(_schema_title_to_class_name(title)) + class_names.append(generated_nested_class_name(title)) else: anonymous_count += 1 @@ -740,11 +743,6 @@ def walk(obj: object, path: tuple[str, ...]) -> None: return class_names, anonymous_count -def _schema_title_to_class_name(title: object) -> str: - words = re.findall(r"[A-Za-z0-9]+", str(title)) - return "".join(word[:1].upper() + word[1:] for word in words) - - def _first_generated_class_name(content: str) -> str | None: try: tree = ast.parse(content) @@ -1429,11 +1427,11 @@ def _dedent_continuation(segment: str, columns: int = 4) -> str: ) -def _union_arm_names(node: ast.expr, into: set[str]) -> None: +def _union_branch_names(node: ast.expr, into: set[str]) -> None: """Collect the names a union expression joins, by their final component.""" if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): - _union_arm_names(node.left, into) - _union_arm_names(node.right, into) + _union_branch_names(node.left, into) + _union_branch_names(node.right, into) elif isinstance(node, ast.Name): into.add(node.id) elif isinstance(node, ast.Attribute): @@ -1469,11 +1467,11 @@ def _plain_model_names(output_dir: Path) -> set[str]: return declared - excluded -def _discriminated_arm_names(output_dir: Path) -> set[str]: - """Names the generated tree uses as an arm of a discriminated union. +def _discriminated_branch_names(output_dir: Path) -> set[str]: + """Names the generated tree uses as an branch of a discriminated union. A wrapper named there is the one choice the parent's discriminator maps its - tag to. ``AssetVariant`` discriminates 21 arms on ``asset_type``, and three + tag to. ``AssetVariant`` discriminates 21 branches on ``asset_type``, and three of them — ``VastAsset``, ``DaastAsset``, ``DisplayTagAsset`` — are each a union of shapes that share one ``asset_type`` and differ on a second property. Replacing one of those with its own union inlines both shapes @@ -1505,30 +1503,30 @@ def _discriminated_arm_names(output_dir: Path) -> set[str]: and any(keyword.arg == "discriminator" for keyword in entry.keywords) for entry in metadata ): - _union_arm_names(union, names) + _union_branch_names(union, names) return names -def _field_union_arm_names(output_dir: Path) -> set[str]: - """Names any module's *field* unions join, when the union has two or more arms. +def _field_union_branch_names(output_dir: Path) -> set[str]: + """Names any module's *field* unions join, when the union has two or more branches. The same-module guard above is not enough on its own: a composing root - that another module's field names as an arm of a smart (non-discriminated) + that another module's field names as an branch of a smart (non-discriminated) union — ``assets: LocalizedCreativeAsset | Assets`` in ``core/creative_localization.py``, where ``LocalizedCreativeAsset`` is a ``oneOf`` root defined in its own module — would be unwrapped, and the - field's union would then compare the root's arms against ``Assets`` - directly. Pydantic's smart mode scores the successful arms (fields set, - exactness), so inlining can select a different arm for the same payload, + field's union would then compare the root's branches against ``Assets`` + directly. Pydantic's smart mode scores the successful branches (fields set, + exactness), so inlining can select a different branch for the same payload, silently. The wrapper keeps the root one choice, as it was. Only field annotations count, and only for union roots: a single-model root is one class whichever way it is spelled. A module-level union *root* that names other - roots as arms — ``core/async-response-data.json`` composing every task + roots as branches — ``core/async-response-data.json`` composing every task response — is a union of unions by schema and was flattened before this - pass existed; keeping its arms wrapped would re-wrap unions adopters + pass existed; keeping its branches wrapped would re-wrap unions adopters already use unwrapped. Discriminated roots are exempt as well: a tagged - union nested as an arm is selected by its tag, not scored. + union nested as an branch is selected by its tag, not scored. """ names: set[str] = set() for py_file in output_dir.rglob("*.py"): @@ -1547,26 +1545,26 @@ def _field_union_arm_names(output_dir: Path) -> set[str]: continue # A wrapper's own ``root:`` annotation restates a root union; # it is the composition this pass rewrites, not a field that - # scores arms. + # scores branches. if isinstance(statement.target, ast.Name) and statement.target.id == "root": continue for union in ast.walk(statement.annotation): if not (isinstance(union, ast.BinOp) and isinstance(union.op, ast.BitOr)): continue - arms: set[str] = set() - _union_arm_names(union, arms) - if len(arms - {"None"}) >= 2: - names.update(arms) + branches: set[str] = set() + _union_branch_names(union, branches) + if len(branches - {"None"}) >= 2: + names.update(branches) return names -def _module_union_arm_names(tree: ast.Module) -> set[str]: +def _module_union_branch_names(tree: ast.Module) -> set[str]: """Names a union inside this module joins. - A wrapper named as an arm of another union in the same module is that - union's single choice, and its own arms are not siblings of the other - arms. Inlining it makes them siblings, and Pydantic then picks a - different arm for the same payload: ``AdcpAgentsAuthorization2`` groups + A wrapper named as an branch of another union in the same module is that + union's single choice, and its own branches are not siblings of the other + branches. Inlining it makes them siblings, and Pydantic then picks a + different branch for the same payload: ``AdcpAgentsAuthorization2`` groups 14 catalog shapes under one choice of ``AdcpAgentsAuthorization``, and ``VastAsset`` groups two delivery shapes under one choice of ``AssetVariant``. @@ -1574,7 +1572,7 @@ def _module_union_arm_names(tree: ast.Module) -> set[str]: names: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): - _union_arm_names(node, names) + _union_branch_names(node, names) return names @@ -1624,8 +1622,8 @@ class TypeName(Target): """ unwrapped_count = 0 kept_count = 0 - keep_wrapped = _discriminated_arm_names(OUTPUT_DIR) - field_union_arms = _field_union_arm_names(OUTPUT_DIR) + keep_wrapped = _discriminated_branch_names(OUTPUT_DIR) + field_union_branches = _field_union_branch_names(OUTPUT_DIR) plain_models = _plain_model_names(OUTPUT_DIR) for py_file in OUTPUT_DIR.rglob("*.py"): @@ -1646,15 +1644,15 @@ class TypeName(Target): for spec in [_composing_root_spec(content, node)] if spec is not None ] - module_arms = _module_union_arm_names(tree) + module_branches = _module_union_branch_names(tree) specs = [ spec for spec in candidates if spec.name not in keep_wrapped - and spec.name not in module_arms + and spec.name not in module_branches and not ( spec.is_union - and spec.name in field_union_arms + and spec.name in field_union_branches and (spec.metadata is None or "discriminator=" not in spec.metadata) ) and (spec.is_union or spec.root.split(".")[-1] in plain_models) @@ -1694,7 +1692,7 @@ def _composing_root_source(spec: _ComposingRootSpec) -> str: return f"class {spec.name}({spec.root}):\n {body}" if spec.metadata is None: # A multi-line union needs brackets of its own to stay one expression; - # ``Annotated[...]`` supplies them in the other arm. + # ``Annotated[...]`` supplies them in the other branch. if "\n" in spec.root: return f"{spec.name} = (\n {spec.root}\n)" return f"{spec.name} = {spec.root}" @@ -2200,19 +2198,19 @@ def fix_reuse_model_discriminator_bug(): def fix_allof_merge_field_override_conflicts() -> None: """Collapse ``allOf``-merge classes whose bases declare conflicting fields. - JSON-Schema ``allOf`` arms that each constrain the same property make + JSON-Schema ``allOf`` branches that each constrain the same property make datamodel-codegen emit a multi-base class. ``postal-area.json``'s native - arm combines ``postal-country-system.json`` (a discriminated union pinning + branch combines ``postal-country-system.json`` (a discriminated union pinning ``country`` to a per-country ``Literal['US']`` and ``system`` to a country-local enum) with its own looser ``country: str`` / ``system: <full enum>`` properties:: - class PostalArea411(AdCPBaseModel): # the loose own-props arm + class PostalArea411(AdCPBaseModel): # the loose own-props branch country: Annotated[str, ...] system: Annotated[PostalCodeSystem, ...] values: Annotated[list[str], ...] - class PostalArea41(AdCPBaseModel): # one country arm + class PostalArea41(AdCPBaseModel): # one country branch country: Annotated[Literal['US'], ...] = 'US' system: Annotated[System, ...] @@ -2221,19 +2219,19 @@ class PostalArea412(PostalArea41, PostalArea411): # allOf merge system: Annotated[PostalCodeSystem, ...] values: Annotated[list[str], ...] - The ``allOf`` intersection of the two arms is the *narrower* country arm: + The ``allOf`` intersection of the two branches is the *narrower* country branch: ``country`` is ``Literal['US']``, not ``str``. The codegen output is wrong twice over — the two bases declare ``country`` / ``system`` with incompatible types (``[misc]``), and the body re-states the loose type on top of the narrow base (``[assignment]``). - The conformant collapse: inherit only from the narrow country arm (selected + The conformant collapse: inherit only from the narrow country branch (selected by its ``Literal``-constrained shared fields), then synthesize each shared - field from the narrow type plus every arm's ``Annotated`` metadata and + field from the narrow type plus every branch's ``Annotated`` metadata and requiredness. Keep, as local fields, fields the dropped loose base contributed that the narrow base lacks (here ``values``). This matters for - helper arms such as ``items: Annotated[Any, Field(min_length=1)]``: choosing - the concrete ``list[Item]`` type must not discard the helper arm's minimum + helper branches such as ``items: Annotated[Any, Field(min_length=1)]``: choosing + the concrete ``list[Item]`` type must not discard the helper branch's minimum length or required status. Pattern-based and schema-agnostic: triggers only when a class's bases @@ -2421,8 +2419,8 @@ def _intersection_field(field: str, keep_base: str, all_bases: list[str]) -> str """Render one field carrying the intersection of all base declarations. The concrete/narrow base supplies the type. ``Annotated`` metadata - from every arm is retained in declaration order (with exact - duplicates removed), and the field is required when any arm makes + from every branch is retained in declaration order (with exact + duplicates removed), and the field is required when any branch makes it required. JSON Schema ``allOf`` combines these properties; none of them may be inferred from Python's base ordering. """ @@ -2450,7 +2448,7 @@ def _intersection_field(field: str, keep_base: str, all_bases: list[str]) -> str ) if required_any_helper: # datamodel-codegen uses ``T | None = None`` for a non-null - # optional JSON property. A required allOf helper arm removes + # optional JSON property. A required allOf helper branch removes # omission, so retaining that synthetic None would wrongly # allow explicit JSON null for the underlying T property. core_type = _without_none_type(core_type) @@ -2473,7 +2471,7 @@ def _intersection_field(field: str, keep_base: str, all_bases: list[str]) -> str ) # An absent AnnAssign value is a required Pydantic field. allOf - # requiredness is a union: one required arm makes the intersection + # requiredness is a union: one required branch makes the intersection # required even when the concrete base allowed omission. if any(declaration.value is None for declaration in unique_declarations): default = "" @@ -2518,7 +2516,7 @@ def _intersection_field(field: str, keep_base: str, all_bases: list[str]) -> str # the common allOf constraint. Preserve that branch identity so # the emitted discriminated union retains one class per # discriminator value; this is a distinct generator artifact, - # not an ordering decision between a loose and narrow schema arm. + # not an ordering decision between a loose and narrow schema branch. literal_conflicts = [ field for field in conflicting_fields @@ -2531,12 +2529,12 @@ def _intersection_field(field: str, keep_base: str, all_bases: list[str]) -> str union_branch_base = in_module_bases[0] if literal_conflicts else None # JSON Schema allOf is order-independent. Codegen's base ordering - # is not a semantic signal, so select the arm that actually + # is not a semantic signal, so select the branch that actually # carries the most finite constraints on the conflicting fields: # Literal fields or references to a local generated Enum. A # concrete conflicting annotation also outranks an ``Any`` - # placeholder (the shape used by adagents minItems helper arms). - # If there is no unique narrow arm, stop regeneration instead of + # placeholder (the shape used by adagents minItems helper branches). + # If there is no unique narrow branch, stop regeneration instead of # silently widening validation based on whichever base happened # to sort first. narrow_scores = { @@ -2569,9 +2567,9 @@ def _intersection_field(field: str, keep_base: str, all_bases: list[str]) -> str keep_base = narrow_bases[0] kept_fields = annotations_by_class[keep_base] - # datamodel-codegen represents property-only constraint arms as + # datamodel-codegen represents property-only constraint branches as # ``Any`` fields. Those declarations contribute metadata and/or - # requiredness, while the sibling arm contributes the usable + # requiredness, while the sibling branch contributes the usable # Python type. Ordinary concrete-vs-concrete conflicts are type # narrowing only and need no local re-declaration. intersection_fields = { @@ -2680,19 +2678,19 @@ def _intersection_field(field: str, keep_base: str, all_bases: list[str]) -> str print(" No allOf-merge field override conflicts found") -def fix_postal_union_arm_order() -> None: - """Prefer the legacy postal arm when a payload omits ``country``. +def fix_postal_union_branch_order() -> None: + """Prefer the legacy postal branch when a payload omits ``country``. - The generated native arm contains country-specific models whose ``country`` - fields have defaults. When that arm appears first, a legacy payload such as + The generated native branch contains country-specific models whose ``country`` + fields have defaults. When that branch appears first, a legacy payload such as ``{"system": "us_zip", ...}`` is accepted as native and serializes with an injected ``country`` that is incompatible with the retained fused system. - The legacy arm forbids extra fields, so putting it first is safe: native - payloads with ``country`` fall through to the native arm. + The legacy branch forbids extra fields, so putting it first is safe: native + payloads with ``country`` fall through to the native branch. """ target = OUTPUT_DIR / "core" / "postal_area.py" if not target.exists(): - print(" postal area model not found (skipping arm-order fix)") + print(" postal area model not found (skipping branch-order fix)") return source = target.read_text() @@ -2703,7 +2701,7 @@ def fix_postal_union_arm_order() -> None: target.write_text(source.replace(old, new)) print(f" core/postal_area.py: reordered {replacements} postal union annotation(s)") elif new in source: - print(" postal area union already prefers the legacy arm") + print(" postal area union already prefers the legacy branch") else: raise RuntimeError("generated postal_area.py has an unexpected outer union shape") @@ -2711,9 +2709,9 @@ def fix_postal_union_arm_order() -> None: def fix_postal_country_system_pairing() -> None: """Restore postal country/system pairing dropped by model generation. - JSON Schema expresses the pairing as ``anyOf`` arms plus an open-country - fallback. datamodel-codegen flattens each arm's referenced postal-system - enum before intersecting it with the arm-local constraint, so generated + JSON Schema expresses the pairing as ``anyOf`` branches plus an open-country + fallback. datamodel-codegen flattens each branch's referenced postal-system + enum before intersecting it with the branch-local constraint, so generated Pydantic models accept combinations such as ``US`` + ``plz``. Inject one before-validator on the stable outer ``PostalArea`` model, deriving every pairing from the bundled schema rather than duplicating spec values here. @@ -2735,8 +2733,8 @@ def fix_postal_country_system_pairing() -> None: fallback_systems: tuple[str, ...] | None = None fallback_excludes: set[str] | None = None - for arm in schema.get("anyOf", []): - properties = arm.get("properties", {}) + for branch in schema.get("anyOf", []): + properties = branch.get("properties", {}) country_schema = properties.get("country", {}) system_schema = properties.get("system", {}) countries = ( @@ -2746,7 +2744,7 @@ def fix_postal_country_system_pairing() -> None: [system_schema["const"]] if "const" in system_schema else system_schema.get("enum") ) if not systems: - raise RuntimeError(f"postal pairing arm has no system constraint: {arm!r}") + raise RuntimeError(f"postal pairing branch has no system constraint: {branch!r}") normalized_systems = tuple(str(value) for value in systems) if countries: for country in countries: @@ -2755,12 +2753,12 @@ def fix_postal_country_system_pairing() -> None: excluded = country_schema.get("not", {}).get("enum") if not excluded: - raise RuntimeError(f"postal pairing fallback has no country exclusion: {arm!r}") + raise RuntimeError(f"postal pairing fallback has no country exclusion: {branch!r}") fallback_excludes = {str(value) for value in excluded} fallback_systems = normalized_systems if not allowed_by_country or fallback_systems is None or fallback_excludes is None: - raise RuntimeError("postal-country-system schema has no complete pairing/fallback arms") + raise RuntimeError("postal-country-system schema has no complete pairing/fallback branches") if fallback_excludes != set(allowed_by_country): raise RuntimeError( "postal-country-system fallback exclusions do not match registered countries: " @@ -3396,6 +3394,812 @@ def _ensure_sequence_import(content: str) -> str: return "from collections.abc import Sequence\n\n" + content +class CompatibilityAnnotation(NamedTuple): + """A generated field whose annotation the implementation replaces. + + The replacement is a fact about the IMPLEMENTATION, not about the pinned + schema: the schema describes one wire shape, and the generated tree renders + it twice -- once per view -- or renders a closed union the spec requires a + consumer to keep open. Either way the field has to carry a type the + generator does not derive. + + Declared here, as a source transform on the field's own declaration, for + the reason ``PIN_GAP_FIELDS`` in ``scripts/generate_types.py`` is declared + as a schema transform: WHERE the type is carried decides whether it works. + Patching the class after generation reaches only the class, and every one + of these fields is nested by generated source BY ITS GENERATED TYPE -- so a + parsed document produced the generated class and the replacement was + reachable only by a caller who named it. Written at the declaration site, + every nesting parent gets it by generation. + + ``expected`` is the guard, moved from import time to generation time. The + runtime patch raised ``RuntimeError`` from an application import when the + generated field lost the shape it was written against; this refuses the + REGENERATION instead, which is where the shape changed. + """ + + #: Path under ``OUTPUT_DIR``, posix-spelled. + module: str + class_name: str + field: str + #: The generated annotation, matched exactly. A miss is a regeneration + #: failure: the field no longer has the shape the replacement was written + #: against. + expected: str + replacement: str + #: Import statements the replacement needs, each written once. + imports: tuple[str, ...] + #: Why the field diverges, cited in the generated output at the field. + because: str + + +COMPATIBILITY_ANNOTATIONS: tuple[CompatibilityAnnotation, ...] = ( + # ``bundled/protocol/get-adcp-capabilities-response.json`` INLINES what the + # canonical tree reaches by ``$ref``, so three canonical schemas are + # generated a second time as identity-distinct classes. A caller composing + # a capabilities response from public SDK objects had to round-trip them + # through ``model_dump()`` first, because the bundled clone is a different + # type with the same wire shape. + CompatibilityAnnotation( + module="bundled/protocol/get_adcp_capabilities_response.py", + class_name="MediaBuy", + field="acceptance_policy_discovery", + expected="AcceptancePolicyDiscovery | None", + replacement="_CanonicalAcceptancePolicyDiscovery | None", + imports=( + "from ...protocol.get_adcp_capabilities_response import " + "AcceptancePolicyDiscovery as _CanonicalAcceptancePolicyDiscovery", + ), + because=( + "The bundled view inlines core/acceptance-policy-discovery.json, which the " + "canonical view reaches by $ref, so the same wire schema is generated twice as " + "two classes. This capability boundary accepts the canonical class, so a typed " + "object composes without a model_dump() round trip." + ), + ), + CompatibilityAnnotation( + module="bundled/protocol/get_adcp_capabilities_response.py", + class_name="Portfolio", + field="publisher_domains", + expected="list[PublisherDomain]", + replacement="list[_CanonicalPublisherDomain]", + imports=( + "from ...core.canonical_product import " "PublisherDomain as _CanonicalPublisherDomain", + ), + because=( + "Same inlined-twice view as acceptance_policy_discovery above. This one is a " + "scalar root rather than an object, which is why the runtime patch this " + "replaced had to match the clone by class identity and not by " + "type[BaseModel] -- a bundled scalar root is a str subclass (#1277)." + ), + ), + CompatibilityAnnotation( + module="bundled/protocol/get_adcp_capabilities_response.py", + class_name="Portfolio", + field="primary_countries", + expected="list[PrimaryCountry] | None", + replacement="list[_CanonicalPrimaryCountry] | None", + imports=( + "from ...protocol.get_adcp_capabilities_response import " + "PrimaryCountry as _CanonicalPrimaryCountry", + ), + because=( + "Same inlined-twice view, and the two renderings do not even share a base: " + "the bundled clone subclasses the inlined Country enum while the canonical " + "class is a ScalarStr. Accepting the canonical one is what makes a composed " + "portfolio typed rather than dict-shaped." + ), + ), + # The one live ergonomic coercion. Annotated flattens, so the inner Annotated + # merges into the field's own and the generated Field(...) is untouched. + CompatibilityAnnotation( + module="media_buy/list_creative_formats_request.py", + class_name="ListCreativeFormatsRequest", + field="disclosure_positions", + expected="list[disclosure_position.DisclosurePosition] | None", + replacement=( + "Annotated[\n list[disclosure_position.DisclosurePosition] | None,\n" + " WrapValidator(coerce_to_unique_enum_list(disclosure_position.DisclosurePosition)),\n ]" + ), + imports=( + "from pydantic import WrapValidator", + "from adcp.types.coercion import coerce_to_unique_enum_list", + ), + because=( + "Order-preserving de-duplication of a repeated filter value. This is the one coercion that pydantic does not already do natively: an enum list accepts strings because the generated enums are StrEnum, and a model field accepts a dict because pydantic does that, but nothing drops a repeat. It was applied by a patch in _ergonomic.py; see salesagent-1jj5e." + ), + ), + CompatibilityAnnotation( + module="media_buy/list_creative_formats_request.py", + class_name="ListCreativeFormatsRequest", + field="disclosure_persistence", + expected="list[disclosure_persistence_1.DisclosurePersistence] | None", + replacement=( + "Annotated[\n list[disclosure_persistence_1.DisclosurePersistence] | None,\n" + " WrapValidator(coerce_to_unique_enum_list(disclosure_persistence_1.DisclosurePersistence)),\n ]" + ), + imports=( + "from pydantic import WrapValidator", + "from adcp.types.coercion import coerce_to_unique_enum_list", + ), + because=( + "Order-preserving de-duplication of a repeated filter value. This is the one coercion that pydantic does not already do natively: an enum list accepts strings because the generated enums are StrEnum, and a model field accepts a dict because pydantic does that, but nothing drops a repeat. It was applied by a patch in _ergonomic.py; see salesagent-1jj5e." + ), + ), + CompatibilityAnnotation( + module="creative/list_creative_formats_request.py", + class_name="ListCreativeFormatsRequestCreativeAgent", + field="disclosure_positions", + expected="list[disclosure_position.DisclosurePosition] | None", + replacement=( + "Annotated[\n list[disclosure_position.DisclosurePosition] | None,\n" + " WrapValidator(coerce_to_unique_enum_list(disclosure_position.DisclosurePosition)),\n ]" + ), + imports=( + "from pydantic import WrapValidator", + "from adcp.types.coercion import coerce_to_unique_enum_list", + ), + because=( + "Order-preserving de-duplication of a repeated filter value. This is the one coercion that pydantic does not already do natively: an enum list accepts strings because the generated enums are StrEnum, and a model field accepts a dict because pydantic does that, but nothing drops a repeat. It was applied by a patch in _ergonomic.py; see salesagent-1jj5e." + ), + ), + CompatibilityAnnotation( + module="creative/list_creative_formats_request.py", + class_name="ListCreativeFormatsRequestCreativeAgent", + field="disclosure_persistence", + expected="list[disclosure_persistence_1.DisclosurePersistence] | None", + replacement=( + "Annotated[\n list[disclosure_persistence_1.DisclosurePersistence] | None,\n" + " WrapValidator(coerce_to_unique_enum_list(disclosure_persistence_1.DisclosurePersistence)),\n ]" + ), + imports=( + "from pydantic import WrapValidator", + "from adcp.types.coercion import coerce_to_unique_enum_list", + ), + because=( + "Order-preserving de-duplication of a repeated filter value. This is the one coercion that pydantic does not already do natively: an enum list accepts strings because the generated enums are StrEnum, and a model field accepts a dict because pydantic does that, but nothing drops a repeat. It was applied by a patch in _ergonomic.py; see salesagent-1jj5e." + ), + ), + # The read-back nodes. Each of these modules already imports the module its + # clone is emitted into -- that is how it referenced the source class -- so + # none of these entries needs an import. + CompatibilityAnnotation( + module="creative/get_creative_delivery_response.py", + class_name="Creative", + field="variants", + expected="list[creative_variant.CreativeVariant]", + replacement="list[creative_variant._DeliveryVariant]", + imports=(), + because=( + "A response carries the tolerant read-back node, not the strict input class: a seller may send an unknown field, or a manifest instance of a sibling class, and neither may become acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in scripts/post_generate_fixes.py." + ), + ), + CompatibilityAnnotation( + module="creative/preview_creative_response.py", + class_name="PreviewCreativeResponse3", + field="manifest", + expected="creative_manifest_1.CreativeManifest | None", + replacement="creative_manifest_1._ReadbackCreativeManifest | None", + imports=(), + because=( + "A response carries the tolerant read-back node, not the strict input class: a seller may send an unknown field, or a manifest instance of a sibling class, and neither may become acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in scripts/post_generate_fixes.py." + ), + ), + CompatibilityAnnotation( + module="media_buy/build_creative_response.py", + class_name="BuildCreativeResponse1", + field="creative_manifest", + expected="creative_manifest_1.CreativeManifest", + replacement="creative_manifest_1._ReadbackCreativeManifest", + imports=(), + because=( + "A response carries the tolerant read-back node, not the strict input class: a seller may send an unknown field, or a manifest instance of a sibling class, and neither may become acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in scripts/post_generate_fixes.py." + ), + ), + CompatibilityAnnotation( + module="media_buy/build_creative_response.py", + class_name="BuildCreativeResponse3", + field="creative_manifests", + expected="list[creative_manifest_1.CreativeManifest]", + replacement="list[creative_manifest_1._ReadbackCreativeManifest]", + imports=(), + because=( + "A response carries the tolerant read-back node, not the strict input class: a seller may send an unknown field, or a manifest instance of a sibling class, and neither may become acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in scripts/post_generate_fixes.py." + ), + ), + CompatibilityAnnotation( + module="media_buy/build_creative_response.py", + class_name="BuildCreativeResponse4", + field="creatives", + expected="list[Creative]", + replacement="list[_BuildReadbackCreative]", + imports=(), + because=( + "A response carries the tolerant read-back node, not the strict input class: a seller may send an unknown field, or a manifest instance of a sibling class, and neither may become acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in scripts/post_generate_fixes.py." + ), + ), + CompatibilityAnnotation( + module="trusted_match/context_match_response.py", + class_name="ContextMatchResponseRouterPublisher", + field="offers", + expected="list[offer.Offer]", + replacement="list[offer._ReadbackOffer]", + imports=(), + because=( + "A response carries the tolerant read-back node, not the strict input class: a seller may send an unknown field, or a manifest instance of a sibling class, and neither may become acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in scripts/post_generate_fixes.py." + ), + ), + CompatibilityAnnotation( + module="trusted_match/provider_context_match_response.py", + class_name="ContextMatchResponseProviderRouter", + field="offers", + expected="list[offer.Offer]", + replacement="list[offer._ReadbackOffer]", + imports=(), + because=( + "A response carries the tolerant read-back node, not the strict input class: a seller may send an unknown field, or a manifest instance of a sibling class, and neither may become acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in scripts/post_generate_fixes.py." + ), + ), + # The rc.3 targeting mutation bridge, on each of the four generated mutation + # fields. ``adcp.types.canonical_creative`` declares the other alias on its two + # facades, because a canonical model materializes JSON before its fields are + # validated; both directions of that split were measured to change results. + CompatibilityAnnotation( + module="media_buy/package_request.py", + class_name="PackageRequest", + field="targeting_overlay", + expected="targeting_input.TargetingOverlayInput | None", + replacement="TargetingOverlayMutation", + imports=("from adcp.types._targeting_compat import TargetingOverlayMutation",), + because=( + "The schema's mutation shape is TargetingOverlayInput and that is what the JSON " + "schema advertises; the SDK additionally accepts an already-resolved " + "TargetingOverlay instance, so a caller passing one back does not have to " + "model_dump() it first. See adcp.types._targeting_compat. Annotated flattens, so " + "the Field(...) below is still this field's own." + ), + ), + CompatibilityAnnotation( + module="media_buy/package_update.py", + class_name="PackageUpdate", + field="targeting_overlay", + expected="targeting_input.TargetingOverlayInput | None", + replacement="TargetingOverlayMutation", + imports=("from adcp.types._targeting_compat import TargetingOverlayMutation",), + because=( + "The schema's mutation shape is TargetingOverlayInput and that is what the JSON " + "schema advertises; the SDK additionally accepts an already-resolved " + "TargetingOverlay instance, so a caller passing one back does not have to " + "model_dump() it first. See adcp.types._targeting_compat. Annotated flattens, so " + "the Field(...) below is still this field's own." + ), + ), + CompatibilityAnnotation( + module="media_buy/package_control.py", + class_name="PackageControl", + field="targeting_overlay", + expected="targeting_input.TargetingOverlayInput | None", + replacement="TargetingOverlayMutation", + imports=("from adcp.types._targeting_compat import TargetingOverlayMutation",), + because=( + "The schema's mutation shape is TargetingOverlayInput and that is what the JSON " + "schema advertises; the SDK additionally accepts an already-resolved " + "TargetingOverlay instance, so a caller passing one back does not have to " + "model_dump() it first. See adcp.types._targeting_compat. Annotated flattens, so " + "the Field(...) below is still this field's own." + ), + ), + CompatibilityAnnotation( + module="media_buy/product_purchase_input.py", + class_name="ProductPurchaseInput", + field="targeting_overlay", + expected="targeting_input.TargetingOverlayInput | None", + replacement="TargetingOverlayMutation", + imports=("from adcp.types._targeting_compat import TargetingOverlayMutation",), + because=( + "The schema's mutation shape is TargetingOverlayInput and that is what the JSON " + "schema advertises; the SDK additionally accepts an already-resolved " + "TargetingOverlay instance, so a caller passing one back does not have to " + "model_dump() it first. See adcp.types._targeting_compat. Annotated flattens, so " + "the Field(...) below is still this field's own." + ), + ), +) + + +def _ensure_module_import(content: str, statement: str) -> str: + """Write ``statement`` ahead of the module's first class, once. + + The generated modules carry several import blocks, written by codegen and + by the passes above, so there is no single block to extend. Ahead of the + first class is after all of them and before any use. + """ + if statement in content: + return content + source, replaced = re.subn( + r"^class ", f"{statement}\n\n\nclass ", content, count=1, flags=re.MULTILINE + ) + if replaced != 1: + raise RuntimeError(f"no class to place {statement!r} ahead of") + return source + + +class OpenAssetUnion(NamedTuple): + """One asset slot's generated union, plus the fallback branch the spec needs.""" + + #: The union's name in the generated module. + alias: str + #: The base class whose direct subclasses are the branches. + base: str + #: The fallback branch's class name. + fallback: str + #: The callable discriminator's function name. + discriminator: str + #: Emit ahead of this class: the one that carries the union, or the first of + #: them, so the name exists before pydantic builds that class. + before_class: str + doc: str + #: ``(class, field)``: the field whose list element type becomes the alias. + carried_by: tuple[str, str] + #: The repeatable-group class, when this union has it as a branch. + group_branch: str | None = None + + +#: The open asset unions, and the module they are emitted into. +#: +#: ``core/format.py`` generates a CLOSED union per asset slot, and the spec +#: requires a consumer to keep an unrecognised ``asset_type`` rather than fail +#: the whole ``list_creative_formats`` response. So each union gains one fallback +#: branch, and the field carries the open union instead -- declared here, written +#: into the module that declares the branches, so a response reaches it by +#: generation rather than by a patch. +#: +#: The VOCABULARY IS DERIVED, which is the point of doing this here. It used to be +#: two hand-written frozensets in ``src/adcp/types/aliases.py`` -- 14 individual +#: and 12 group members -- beside a generated union of 21 and 21. Everything +#: outside the literal fell to the fallback, so seven individual asset types the +#: PINNED schema declares (``zip``, ``display_tag``, ``published_post``, ``card``, +#: ``pixel_tracker``, ``vast_tracker``, ``daast_tracker``) and nine group types +#: parsed as the unknown branch with their typed fields in ``__pydantic_extra__``, +#: with generated classes for every one of them sitting unused. See +#: salesagent-d2wuv. The generator knows each branch's ``asset_type`` literal +#: because it emitted it, so nothing is declared and nothing can go stale. +_OPEN_ASSET_UNIONS: tuple[OpenAssetUnion, ...] = ( + OpenAssetUnion( + alias="GroupFormatAssetUnion", + base="BaseGroupAsset", + fallback="UnknownGroupAsset", + discriminator="_group_asset_discriminator", + # Assets29, the repeatable group, carries this union, and every group + # branch is declared ahead of it. + before_class="Assets29", + carried_by=("Assets29", "assets"), + doc=( + "Open discriminated union for the slots inside a repeatable asset group.\\n\\n" + " The generated union closed over the group asset classes; this adds the\\n" + " fallback branch an unrecognised ``asset_type`` needs." + ), + ), + OpenAssetUnion( + alias="FormatAssetUnion", + base="BaseIndividualAsset", + fallback="UnknownFormatAsset", + discriminator="_format_asset_discriminator", + # This union includes the repeatable group as a branch, so it comes after + # Assets29 and before Format, which is the class that carries it. + before_class="Format", + carried_by=("Format", "assets"), + group_branch="Assets29", + doc=( + "Open discriminated union for ``Format.assets``.\\n\\n" + " The generated union closed over the individual asset classes plus the\\n" + " repeatable group; this adds the fallback branch an unrecognised\\n" + " ``asset_type`` needs." + ), + ), +) + + +def _asset_branches(tree: ast.Module, base: str) -> list[tuple[str, str]]: + """``(class name, asset_type literal)`` for every direct subclass of ``base``. + + Read off the generated source, in declaration order. A ``Tag`` must be unique + within a union, so a duplicate literal is refused rather than silently + dropping whichever branch came second. + """ + branches: list[tuple[str, str]] = [] + seen: dict[str, str] = {} + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + if base not in {ast.unparse(parent) for parent in node.bases}: + continue + for statement in node.body: + if not isinstance(statement, ast.AnnAssign): + continue + if getattr(statement.target, "id", None) != "asset_type": + continue + literal = _extract_single_literal_value(statement.annotation) + if not isinstance(literal, str): + continue + if literal in seen: + raise RuntimeError( + f"core/format.py: asset_type {literal!r} is declared by both " + f"{seen[literal]} and {node.name}; a discriminated union cannot " + "tag two branches the same" + ) + seen[literal] = node.name + branches.append((node.name, literal)) + if not branches: + raise RuntimeError(f"core/format.py: no subclass of {base} declares an asset_type") + return branches + + +def _render_open_asset_union(union: OpenAssetUnion, branches: list[tuple[str, str]]) -> str: + """The fallback class, its discriminator, and the tagged union, as source.""" + known = "".join(f" {literal!r},\n" for _name, literal in branches) + arms = [f"Annotated[{name}, Tag({literal!r})]" for name, literal in branches] + if union.group_branch: + arms.append(f"Annotated[{union.group_branch}, Tag('repeatable_group')]") + arms.append(f"Annotated[{union.fallback}, Tag('_unknown')]") + # The first arm carries no leading ``|``; black keeps this layout. + tags = f" {arms[0]}\n" + "".join(f" | {arm}\n" for arm in arms[1:]) + group_route = ( + " if item_type == 'repeatable_group':\n return 'repeatable_group'\n" + if union.group_branch + else "" + ) + read_item_type = ( + " item_type = value.get('item_type', 'individual') if isinstance(value, dict) " + "else getattr(value, 'item_type', 'individual')\n" + if union.group_branch + else "" + ) + return f'''class {union.fallback}({union.base}): + """Fallback branch for an ``asset_type`` outside this pin's vocabulary. + + A response carrying an asset type defined after this SDK's pin parses as this + class instead of failing, which would zero out every format in the response. + The structural fields are still validated; the type-specific ones are kept in + ``__pydantic_extra__``. + + ``extra='allow'`` is deliberate and not inherited from the strict-validation + policy on ``AdCPBaseModel``: dropping or refusing the unknown fields defeats + the whole purpose of the branch. Read path only -- a request keeps its closed + ``Literal`` branches. + """ + + model_config = ConfigDict(extra='allow') + asset_type: str + + +#: The ``asset_type`` values this pin has a class for. Derived from the branches +#: above, so a schema that adds a type adds its class AND its tag together. +_KNOWN_{union.base.upper()}_TYPES: frozenset[str] = frozenset( + {{ +{known} }} +) + + +def {union.discriminator}(value: Any) -> str: + """Route a wire asset to its ``Tag``, or to the fallback.""" +{read_item_type}{group_route} asset_type = ( + value.get('asset_type', '') + if isinstance(value, dict) + else getattr(value, 'asset_type', '') + ) + return asset_type if asset_type in _KNOWN_{union.base.upper()}_TYPES else '_unknown' + + +{union.alias} = Annotated[ +{tags} , + Discriminator({union.discriminator}), +] +"""{union.doc} +"""''' + + +def _union_class_names(node: ast.AST, aliases: dict[str, ast.AST]) -> set[str]: + """Every class name a possibly-aliased union annotation resolves to. + + ``Format.assets`` spells its union inline; ``Assets29.assets`` reaches one + through a generated ``Assets30 = Annotated[A | B, Field(discriminator=...)]`` + alias, so the alias is followed rather than counted as a branch. + """ + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return _union_class_names(node.left, aliases) | _union_class_names(node.right, aliases) + if isinstance(node, ast.Subscript) and _subscript_base_name(node) == "Annotated": + inner = node.slice.elts[0] if isinstance(node.slice, ast.Tuple) else node.slice + return _union_class_names(inner, aliases) + if isinstance(node, ast.Name): + if node.id in aliases: + return _union_class_names(aliases[node.id], aliases) + return {node.id} + return {ast.unparse(node)} + + +def _module_union_aliases(tree: ast.Module) -> dict[str, ast.AST]: + """Top-level ``Name = <annotation>`` bindings, so a union alias can be followed.""" + return { + node.targets[0].id: node.value + for node in tree.body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + } + + +def _carry_open_asset_union(content: str, union: OpenAssetUnion, expected: set[str]) -> str: + """Point ``union.carried_by`` at the alias, after checking what it carries now. + + The membership check is the guard, and it is DERIVED rather than declared: the + field's current element union must resolve to exactly the branch set the alias + was just built from. A schema refresh that adds an asset type changes both + sides together and passes; one that reshapes the field, or that adds a branch + the base does not own, fails here rather than leaving the field closed. + """ + class_name, field_name = union.carried_by + block = _find_class_field_block(content, class_name, field_name) + if block is None: + raise RuntimeError( + f"core/format.py: {class_name}.{field_name} is not declared, so " + f"{union.alias} has nothing to open" + ) + start, end = block + annotation = content[start:end] + element = re.search(r"list\[(.*?)\]\s*(?:\|\s*None)?,", annotation, re.DOTALL) + if element is None: + raise RuntimeError( + f"core/format.py: {class_name}.{field_name} is not a list annotation " + f"({annotation.strip()[:80]!r}); {union.alias} assumes it is" + ) + aliases = _module_union_aliases(ast.parse(content)) + # Parenthesized: a generated union spans lines, and the continuation lines are + # indented, which bare ``ast.parse`` reads as an IndentationError. + carried = _union_class_names( + ast.parse(f"({element.group(1).strip()})", mode="eval").body, aliases + ) + if carried != expected: + raise RuntimeError( + f"core/format.py: {class_name}.{field_name} carries {sorted(carried)}, but " + f"{union.alias} was built from {sorted(expected)}. The generated union and " + "the branch derivation disagree, so the field would not be opened over the " + "classes the union covers." + ) + opened = annotation[: element.start(1)] + union.alias + annotation[element.end(1) :] + return content[:start] + opened + content[end:] + + +def emit_open_asset_unions() -> None: + """Write each :data:`_OPEN_ASSET_UNIONS` definition into ``core/format.py``. + + Raises when the module, a base, or an anchor class is missing: an asset union + that silently failed to open would turn every unrecognised asset type into a + whole-response validation failure, which is the thing it exists to prevent. + """ + print("Emitting the open asset unions into core/format.py...") + + path = OUTPUT_DIR / "core" / "format.py" + if not path.exists(): + raise RuntimeError("core/format.py does not exist; the asset unions have no home") + content = path.read_text() + for union in _OPEN_ASSET_UNIONS: + branches = _asset_branches(ast.parse(content), union.base) + anchor = re.compile(rf"^class {re.escape(union.before_class)}\b", re.MULTILINE) + match = anchor.search(content) + if match is None: + raise RuntimeError( + f"core/format.py: no 'class {union.before_class}' to emit " + f"{union.alias} ahead of" + ) + content = ( + content[: match.start()] + + _render_open_asset_union(union, branches) + + "\n\n\n" + + content[match.start() :] + ) + expected = {name for name, _literal in branches} + if union.group_branch: + expected.add(union.group_branch) + content = _carry_open_asset_union(content, union, expected) + print( + f" ✓ {union.alias}: {len(branches)} branch(es) plus the fallback, carried by " + f"{union.carried_by[0]}.{union.carried_by[1]}" + ) + content = _ensure_module_import(content, "from pydantic import Discriminator, Tag") + path.write_text(content) + + +class ReadbackClone(NamedTuple): + """A tolerant read-back node, emitted next to the class whose fields it copies. + + A response must accept what a seller sends -- an unknown field kept, a + manifest INSTANCE of a sibling class -- and a request must accept neither + (#1241). So the tolerance lives on a separate class that COPIES the input's + fields without inheriting from it; ``adcp.types._readback.readback_clone`` + builds it, and that module imports nothing from the generated tree. + + The definition is emitted HERE, into the generated module, rather than kept + in a hand-written one, because ``_BuildReadbackCreative`` copies + ``media_buy/build_creative_response.Creative`` and the field that carries it + is in that same module -- a hand-written home would have to import the + module that imports it. One mechanism for all five beats four of them living + somewhere tidier and one special case. + + No entry needs a new import for its CONSUMERS: a module that referenced the + source class already imports the module the clone is emitted into. + """ + + #: Path under ``OUTPUT_DIR``, posix-spelled: the module that declares the + #: class whose fields are copied. + module: str + name: str + #: The definition, verbatim. This is what lands in the generated output, so + #: it is written the way it should read there. + definition: str + #: Import statements the definition needs, each written once. + imports: tuple[str, ...] + #: Emit ahead of this class. ``None`` appends at the end of the module, + #: which is correct unless a class LATER in the same module carries it. + before_class: str | None + + +READBACK_CLONES: tuple[ReadbackClone, ...] = ( + ReadbackClone( + module="core/creative_manifest.py", + name="_ReadbackCreativeManifest", + definition="""_ReadbackCreativeManifest = readback_clone( + "_ReadbackCreativeManifest", + # Not one branch's shape -- a readback node accepts a manifest instance of + # either branch -- so it copies the base they share. + shared_base(CreativeManifest), + # No field override: ``format_kind`` is generated as ``str`` (see + # OPEN_VOCABULARY_SCHEMAS in scripts/generate_types.py), so there is nothing left + # to widen. What this clone carries is instance normalization -- a readback node + # accepts a manifest INSTANCE of a sibling class, which a plain field does not. + validators={ + "_coerce_standalone_assets": model_validator(mode="before")( + readback_normalizer(CreativeManifest, "_coerce_standalone_assets") + ), + }, +)""", + imports=( + "from adcp.types._readback import readback_clone, readback_normalizer", + "from adcp.types._split_root import shared_base", + ), + before_class=None, + ), + ReadbackClone( + module="core/creative_variant.py", + name="_DeliveryVariant", + definition="""_DeliveryVariant = readback_clone( + "_DeliveryVariant", + CreativeVariant, + {"manifest": creative_manifest._ReadbackCreativeManifest | None}, +)""", + imports=("from adcp.types._readback import readback_clone",), + before_class=None, + ), + ReadbackClone( + module="media_buy/build_creative_response.py", + name="_BuildReadbackVariant", + definition="""_BuildReadbackVariant = readback_clone( + "_BuildReadbackVariant", + Variant, + {"creative_manifest": creative_manifest_1._ReadbackCreativeManifest}, +)""", + imports=("from adcp.types._readback import readback_clone",), + # BuildCreativeResponse4 is later in this module and carries the clone + # below, so both definitions are emitted ahead of the response classes. + before_class="BuildCreativeResponse1", + ), + ReadbackClone( + module="media_buy/build_creative_response.py", + name="_BuildReadbackCreative", + definition="""_BuildReadbackCreative = readback_clone( + "_BuildReadbackCreative", + Creative, + { + # This constraint is inside the optional union in the generated type, so it + # has to stay on the non-None branch when that annotation is replaced. + "variants": Annotated[list[_BuildReadbackVariant], Field(min_length=1)] | None + }, +)""", + imports=("from adcp.types._readback import readback_clone",), + before_class="BuildCreativeResponse1", + ), + ReadbackClone( + module="trusted_match/offer.py", + name="_ReadbackOffer", + definition="""_ReadbackOffer = readback_clone( + "_ReadbackOffer", + Offer, + {"creative_manifest": creative_manifest_1._ReadbackCreativeManifest | None}, +)""", + imports=("from adcp.types._readback import readback_clone",), + before_class=None, + ), +) + + +def emit_readback_clones() -> None: + """Write each :data:`READBACK_CLONES` definition into its generated module. + + Raises when the module or the class it copies is not there: a clone whose + source class moved would otherwise be built from whatever still answers to + the name, and the field that carries it would be silently un-tolerant. + """ + print("Emitting read-back clones beside the classes they copy...") + + for clone in READBACK_CLONES: + path = OUTPUT_DIR / clone.module + if not path.exists(): + raise RuntimeError(f"{clone.module} does not exist; {clone.name} has no source class") + content = path.read_text() + if clone.before_class is None: + content = content.rstrip("\n") + "\n\n\n" + clone.definition + "\n" + else: + anchor = re.compile(rf"^class {re.escape(clone.before_class)}\b", re.MULTILINE) + match = anchor.search(content) + if match is None: + raise RuntimeError( + f"{clone.module}: no 'class {clone.before_class}' to emit " + f"{clone.name} ahead of" + ) + content = ( + content[: match.start()] + clone.definition + "\n\n\n" + content[match.start() :] + ) + for statement in clone.imports: + content = _ensure_module_import(content, statement) + path.write_text(content) + print(f" ✓ {clone.module}: {clone.name}") + + +def declare_compatibility_annotations() -> None: + """Write each :data:`COMPATIBILITY_ANNOTATIONS` entry at its declaration site. + + Raises when an entry's ``expected`` annotation is not what the generator + emitted. That is the whole point of carrying it: the shape a replacement + was written against is asserted at the moment of the replacement, so a + schema refresh that reshapes the field fails the regeneration rather than + silently installing a type for a field that no longer has that form. + """ + print("Declaring compatibility annotations at their declaration sites...") + + for entry in COMPATIBILITY_ANNOTATIONS: + path = OUTPUT_DIR / entry.module + if not path.exists(): + raise RuntimeError(f"{entry.module} does not exist; {entry.class_name} is not emitted") + content = path.read_text() + block = _find_class_field_block(content, entry.class_name, entry.field) + if block is None: + raise RuntimeError( + f"{entry.module}: {entry.class_name}.{entry.field} is not declared, so the " + "compatibility annotation has no site" + ) + start, end = block + annotation = content[start:end] + if entry.expected not in annotation: + raise RuntimeError( + f"{entry.module}: {entry.class_name}.{entry.field} is generated as " + f"{annotation.strip()!r}, which does not contain {entry.expected!r}. The " + "replacement was written against a shape the field no longer has." + ) + line_start = start - len(f" {entry.field}: ") + citation = "".join(f" # {line}\n" for line in textwrap.wrap(entry.because, width=92)) + content = ( + content[:line_start] + + citation + + content[line_start:start] + + annotation.replace(entry.expected, entry.replacement, 1) + + content[end:] + ) + for statement in entry.imports: + content = _ensure_module_import(content, statement) + path.write_text(content) + print(f" ✓ {entry.module}: {entry.class_name}.{entry.field} -> {entry.replacement}") + + # Matches request-type 'canceled: Literal[True] = True' emissions. # datamodel-codegen emits '= True' directly from "const": true boolean # schema properties — it is NOT produced by inject_literal_discriminator_defaults() @@ -3932,14 +4736,14 @@ def _require_identity_match_dimensions(self) -> TmpProviderRegistration1: validators_1 + "\n\nclass TmpProviderRegistration2(AdCPBaseModel):", 1, ) - # Arm 2 is the last class in the module, so anchor on the end + # Branch 2 is the last class in the module, so anchor on the end # of its own body rather than on whatever follows it. - arm_2_end = _class_end_offset(source, "TmpProviderRegistration2") - if arm_2_end is None: + branch_2_end = _class_end_offset(source, "TmpProviderRegistration2") + if branch_2_end is None: raise RuntimeError( "generated provider_registration.py has no TmpProviderRegistration2" ) - source = source[:arm_2_end] + validators_2 + source[arm_2_end:] + source = source[:branch_2_end] + validators_2 + source[branch_2_end:] provider_registration.write_text(source) print(" trusted_match/provider_registration.py: added runtime validators") else: @@ -4005,9 +4809,7 @@ def _require_identity_match_dimensions(self) -> TmpProviderRegistration1: print(" trusted_match/identity_match_response.py response class not found") return response_class = response_class_match.group(1) - source = ( - source.rstrip() - + f""" + source = source.rstrip() + f""" @model_validator(mode='after') def _validate_tmpx_provider_ids(self) -> {response_class}: @@ -4022,7 +4824,6 @@ def _validate_tmpx_provider_ids(self) -> {response_class}: raise ValueError('tmpx_providers keys must be valid provider_id values') return self """ - ) identity_match_response.write_text(source.rstrip() + "\n") print(" trusted_match/identity_match_response.py: added runtime validators") @@ -4125,49 +4926,6 @@ def fix_beta3_secure_url_constraints() -> None: print(f" {relative_path}: restored secure URL constraint") -def fix_beta3_package_request_constraints() -> None: - """Restore beta.3 PackageRequest conditionals dropped by codegen.""" - - target = OUTPUT_DIR / "media_buy" / "package_request.py" - if not target.exists(): - print(" media_buy/package_request.py not found (skipping beta.3 constraints)") - return - source = target.read_text() - if "def _validate_format_params(" in source: - print(" media_buy/package_request.py beta.3 constraints already fixed") - return - source, import_count = re.subn( - r"^(from pydantic import .+)$", - r"\1, model_validator", - source, - count=1, - flags=re.MULTILINE, - ) - if import_count != 1: - print(" media_buy/package_request.py pydantic import shape not found") - return - class_start = source.find("class PackageRequest(") - if class_start == -1: - print(" media_buy/package_request.py PackageRequest class not found") - return - class_end = source.find("\n\nclass ", class_start) - if class_end == -1: - class_end = len(source) - validator = """ - - @model_validator(mode='after') - def _validate_format_params(self) -> PackageRequest: - if self.params is not None and self.format_kind is None: - raise ValueError('params requires format_kind') - if self.params is not None and self.format_kind == 'image': - if ('width' in self.params) != ('height' in self.params): - raise ValueError('image params width and height must co-occur') - return self""" - class_block = source[class_start:class_end].rstrip() + validator - target.write_text((source[:class_start] + class_block + source[class_end:]).rstrip() + "\n") - print(" media_buy/package_request.py: restored beta.3 format param constraints") - - def fix_beta3_adagents_renderer_constraints() -> None: """Preserve optional catalog role and reference-renderer trust gates.""" @@ -4674,7 +5432,7 @@ def restore_format_asset_numbered_aliases() -> None: def restore_principal_result_aliases() -> None: - """Expose principal result arms by discriminator instead of numeric suffix. + """Expose principal result branches by discriminator instead of numeric suffix. datamodel-code-generator numbers anonymous ``result`` variants according to aggregate schema traversal order. Those numbers are implementation @@ -4729,7 +5487,7 @@ def restore_principal_result_aliases() -> None: target.write_text( source.rstrip() - + "\n\n\n# Stable aliases for anonymous result arms (selected by discriminator).\n" + + "\n\n\n# Stable aliases for anonymous result branches (selected by discriminator).\n" + "\n".join(assignments) + "\n" ) @@ -4739,6 +5497,12 @@ def restore_principal_result_aliases() -> None: def disambiguate_comply_response_arm() -> None: """Give the comply response's anonymous ``Arm`` enum a stable name. + Keeps the word ``arm`` while the rest of the constraint vocabulary says + ``branch``, because this ``Arm`` is not a combinator's branch: the + compliance response declares an ``arm`` PROPERTY, and the generator names + the enum after the wire field. Renaming it would rename a protocol name + and break the generated public class ``ComplyResponseArm`` with it. + The request schema already generates an unrelated public ``Arm`` enum. Codegen 0.63 also calls the response enum ``Arm``, which trips the exact collision guard only after regeneration. Rename the response-local type @@ -4857,9 +5621,7 @@ def restore_flattened_contract_field_types() -> None: if "@model_validator(mode='before')" not in source: source = ensure_pydantic_import(source, "model_validator") - source = ( - source.rstrip() - + """ + source = source.rstrip() + """ @model_validator(mode='before') @classmethod @@ -4874,7 +5636,6 @@ def _reject_seller_bound_manifest_fields(cls, data: Any) -> Any: ) return data """ - ) representation_target.write_text(source) print(" core/creative_representation.py: restored canonical format contract") @@ -4907,9 +5668,7 @@ def enforce_transformer_output_contract() -> None: if "class Transformer(" not in source: raise RuntimeError("transformer.py: Transformer class not found") source = ensure_pydantic_import(source, "model_validator") - target.write_text( - source.rstrip() - + """ + target.write_text(source.rstrip() + """ @model_validator(mode='after') def _require_output_format_declaration(self) -> Transformer: @@ -4924,20 +5683,30 @@ def _require_output_format_declaration(self) -> Transformer: 'one of output_capability_ids or deprecated output_format_ids is required' ) return self -""" - ) +""") print(" core/transformer.py: enforced output declaration requirement") def restore_constructible_response_bases() -> None: """Keep selected public response names as constructible Pydantic models. - The generated numbered arms are still the schema-specific parsing surface and - remain available through the existing aliases. A top-level union alias, - however, is not constructible and breaks callers that used the stable response - model API. Restore the former envelope base and make every generated arm a - subclass of it. The shared mixin dispatches ``Base.model_validate`` through - the arms, preserving both forms without losing task-specific wire fields. + The generated numbered branches are still the schema-specific parsing + surface and remain available through the existing aliases. A top-level union + alias, however, is not constructible and breaks callers that used the stable + response model API. Restore the former envelope base, make every generated + branch a subclass of it, and give the base the shared dispatch hook so the + public name resolves to the branch a document satisfies. + + The base used to carry a ``model_validate`` override instead, which its own + docstring described as keeping the cross-field rules enforced. It did not. + Pydantic validates a nested field and a ``TypeAdapter`` through the CORE + SCHEMA, so the override was invisible on both -- and the base is + ``extra="allow"``, so on those paths it accepted ANY document and filed + every field as an untyped extra. ``validate_union``, the SDK's own public + helper and the one the server framework calls on a handler's return value, + is a ``TypeAdapter``: it accepted ``{"totally": "wrong"}`` as a + ``CreateContentStandardsResponse``. The hook is the same one the split + roots use, so one mechanism answers every path. """ response_specs = ( ("compliance/comply_test_controller_response.py", "ComplyTestControllerResponse"), @@ -4956,31 +5725,27 @@ def restore_constructible_response_bases() -> None: for relative_path, base_name in response_specs: target = OUTPUT_DIR / relative_path if not target.exists(): - print(f" {relative_path} not found (skipping constructible response base)") - continue - - source = target.read_text() - if _RESPONSE_ARM_DISPATCH_IMPORT not in source: - future_import = "from __future__ import annotations\n\n" - if future_import not in source: - raise RuntimeError(f"{relative_path}: missing future annotations import") - source = source.replace( - future_import, future_import + _RESPONSE_ARM_DISPATCH_IMPORT + "\n", 1 - ) + # A row of this table names a PUBLIC response name this package + # keeps constructible, so a row the tree no longer answers is a + # decision gone stale, not a case to step over. It used to print a + # line and continue, which left the name whatever loose shape the + # generator gave it. + raise RuntimeError(f"{relative_path}: no generated module for {base_name}") + + source = _ensure_branch_dispatch_import(target.read_text()) try: tree = ast.parse(source) except SyntaxError as exc: raise RuntimeError(f"{relative_path}: invalid generated Python") from exc - arms = [ + branches = [ node for node in tree.body if isinstance(node, ast.ClassDef) and re.fullmatch(rf"{re.escape(base_name)}\d+", node.name) ] - if not arms: - print(f" {relative_path}: response arms not generated (skipping constructible base)") - continue + if not branches: + raise RuntimeError(f"{relative_path}: {base_name} generated no numbered branches") stable_base_exists = any( isinstance(node, ast.ClassDef) and node.name == base_name for node in tree.body @@ -4998,68 +5763,122 @@ def restore_constructible_response_bases() -> None: lines[line_number] = "" source = "".join(lines) - arm_names = [ - arm.name for arm in sorted(arms, key=lambda arm: int(arm.name.removeprefix(base_name))) + branch_names = [ + branch.name + for branch in sorted( + branches, key=lambda branch: int(branch.name.removeprefix(base_name)) + ) ] - for arm in arms: - arm_header = re.compile(rf"^class {re.escape(arm.name)}\([^\n]*\):$", re.MULTILINE) - source, replacements = arm_header.subn( - f"class {arm.name}({base_name}):", source, count=1 + for branch in branches: + header = re.compile(rf"^class {re.escape(branch.name)}\([^\n]*\):$", re.MULTILINE) + source, replacements = header.subn( + f"class {branch.name}({base_name}):", source, count=1 ) if replacements != 1: - raise RuntimeError(f"{relative_path}: unable to rewrite {arm.name} base") - - first_arm = min(arms, key=lambda arm: arm.lineno) - arm_marker = f"class {first_arm.name}(" - first_arm_position = source.find(arm_marker) - if first_arm_position < 0: - raise RuntimeError(f"{relative_path}: unable to locate {first_arm.name}") - arm_list = ",\n ".join(arm_names) - compatibility_base = f"""class {base_name}(ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): - \"\"\"Constructible compatibility base for generated response arms.\"\"\" - - @classmethod - def _response_arm_models(cls) -> tuple[type[{base_name}], ...]: - return ( - {arm_list}, + raise RuntimeError(f"{relative_path}: unable to rewrite {branch.name} base") + + first_branch = min(branches, key=lambda branch: branch.lineno) + first_branch_position = source.find(f"class {first_branch.name}(") + if first_branch_position < 0: + raise RuntimeError(f"{relative_path}: unable to locate {first_branch.name}") + declaration = "\n".join( + [ + f"class {base_name}(BranchDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope):", + ' """Constructible compatibility base for generated response branches."""', + *render_root_required(_response_root_required(relative_path)), + *render_branch_models(branch_names, _response_root_combinator(relative_path)), + ] ) - - -""" + compatibility_base = f"{declaration}\n\n\n" if not stable_base_exists: - source = source[:first_arm_position] + compatibility_base + source[first_arm_position:] + source = ( + source[:first_branch_position] + compatibility_base + source[first_branch_position:] + ) else: base_marker = f"class {base_name}(" base_position = source.find(base_marker) if base_position < 0: raise RuntimeError(f"{relative_path}: unable to update {base_name} base") - source = source[:base_position] + compatibility_base + source[first_arm_position:] - + source = source[:base_position] + compatibility_base + source[first_branch_position:] + + # The base declares ``_ROOT_REQUIRED``, and these five modules carry no + # ``_ABSENT`` to have brought the annotation in already. Without it mypy + # reads the declaration as an INSTANCE variable overriding the mixin's + # class variable -- two errors per module, and a pydantic FIELD named + # ``_ROOT_REQUIRED`` would be a different thing from the attribute + # ``name_the_combinator`` reads. + source = _ensure_typing_import(source, "ClassVar") + + # The branches are declared BELOW the base, so ``_branch_models`` cannot + # resolve them while the class body is compiled and the hook returns the + # base's own schema. This rebuild is what installs the union -- the same + # two-step the split roots use, in the module that declares the class. + source = f"{source.rstrip()}\n\n\n{base_name}.model_rebuild(force=True)\n" target.write_text(source.rstrip() + "\n") print(f" {relative_path}: restored constructible {base_name} base") +def _schema_for_generated_module() -> dict[Path, Path]: + """Which schema each generated module came from, keyed by module path. + + The forward map is lossy -- ``generated_module_for`` replaces ``-`` with + ``_`` in every path part -- so the inverse is built by walking the bundle + rather than by undoing the substitution. + + Deliberately uncached. ``SCHEMA_DIR`` is a module global that tests + monkeypatch, and a cache built under a synthetic bundle would answer for + the real one afterwards. + """ + return { + generated_module_for(schema_file.relative_to(SCHEMA_DIR)).relative_to( + OUTPUT_DIR + ): schema_file.relative_to(SCHEMA_DIR) + for schema_file in sorted(SCHEMA_DIR.rglob("*.json")) + } + + +def _response_root_combinator(relative_path: str) -> str: + """The keyword a refusal from this response's base will name. + + Read from the root's own schema, not spelled in the table above: a hand + written keyword is a sentence about a schema that can stop being true, and + this one reaches the wire in ``issues[].keyword``. + """ + schema_rel = _schema_for_generated_module().get(Path(relative_path)) + if schema_rel is None: + raise RuntimeError(f"{relative_path}: no bundled schema generates this module") + combinator = root_combinator(json.loads((SCHEMA_DIR / schema_rel).read_text())) + if combinator is None: + raise RuntimeError(f"{schema_rel}: root declares no anyOf or oneOf over its branches") + return combinator + + +def _response_root_required(relative_path: str) -> frozenset[str]: + """What this response's SCHEMA ROOT requires, for the compatibility base. + + Read from the schema beside :func:`_response_root_combinator`, and emitted + through the same ``render_root_required`` the branch emitter uses, so the + two kinds of split root declare the set in one place and the same words. + + Without it these five bases declared an empty set while their schema roots + require ``status`` -- which they hold through ``ProtocolEnvelope`` -- and + ``name_the_combinator`` would read a missing ``status`` as the key a branch + selects by on 5 of the 18 split roots (salesagent-8qela.116). + """ + schema_rel = _schema_for_generated_module().get(Path(relative_path)) + if schema_rel is None: + raise RuntimeError(f"{relative_path}: no bundled schema generates this module") + schema = json.loads((SCHEMA_DIR / schema_rel).read_text()) + return root_required_names(schema, bundle_document_resolver(SCHEMA_DIR)) + + def _schema_permits_null(schema: Any) -> bool: - """True when a JSON Schema property explicitly permits ``null``. + """Deprecated spelling of :func:`scripts.schema_constraints.permits_null`. - ``required`` and nullability are independent axes in JSON Schema: a - property listed in ``required`` whose ``type`` array contains ``"null"`` - must be *present* and may be *null*. Both spellings count here — the - ``type: ["string", "null"]`` array form and a ``oneOf``/``anyOf`` branch - of ``{"type": "null"}``. + Kept as the name the response emitter already calls; the predicate has one + owner, which is the module that reads schema nodes. """ - if not isinstance(schema, dict): - return False - schema_type = schema.get("type") - if isinstance(schema_type, list) and "null" in schema_type: - return True - for keyword in ("oneOf", "anyOf"): - variants = schema.get(keyword) - if isinstance(variants, list) and any( - isinstance(variant, dict) and variant.get("type") == "null" for variant in variants - ): - return True - return False + return permits_null(schema) def _top_level_union_parts(annotation: str) -> list[str]: @@ -5089,11 +5908,11 @@ def _union_with_none(annotation: str) -> str: def restore_response_variant_aliases() -> None: - """Restore numbered response arms from schema data, not hand-written payloads. + """Restore numbered response branches from schema data, not hand-written payloads. datamodel-code-generator currently collapses several task responses to a common envelope class with no task-specific fields. The SDK still exposes - numbered response-arm classes for ergonomic construction/parsing aliases. + numbered response-branch classes for ergonomic construction/parsing aliases. Keep those public names, but derive their fields from the JSON schemas so the compatibility layer cannot silently drift from protocol shape. """ @@ -5507,16 +6326,16 @@ def composes_protocol_envelope(self, schema: dict[str, Any]) -> bool: return True return False - def emit_response_class(self, class_name: str, arm: dict[str, Any]) -> str: - props = arm.get("properties") or {} - required = set(arm.get("required") or []) + def emit_response_class(self, class_name: str, branch: dict[str, Any]) -> str: + props = branch.get("properties") or {} + required = set(branch.get("required") or []) is_submitted = ( props.get("status", {}).get("const") == "submitted" and "task_id" in props ) # A root-level ``allOf`` applies to the whole document, so every - # ``oneOf`` arm of a response whose root composes + # ``oneOf`` branch of a response whose root composes # ``core/protocol-envelope.json`` carries the protocol envelope: - # success arms, error arms and submitted arms alike. + # success branches, error branches and submitted branches alike. inherits_protocol_envelope = is_submitted or self.root_composes_protocol_envelope bases = ( "AdcpVersionEnvelope, ProtocolEnvelope" @@ -5536,7 +6355,7 @@ def emit_response_class(self, class_name: str, arm: dict[str, Any]) -> str: } and class_name.endswith("1") if is_sync_media_buy_success and "status" not in props: # AdCP 3.2 omits the 3.1 synchronous task-envelope status from - # this arm. The SDK still declares it for negotiated 3.0/3.1 + # this branch. The SDK still declares it for negotiated 3.0/3.1 # compatibility and so the legacy normalizer never injects an # unknown extra into ``extra='forbid'`` subclasses. self.typing_imports.add("Literal") @@ -5624,17 +6443,18 @@ def emit_response_class(self, class_name: str, arm: dict[str, Any]) -> str: def render(self, schema: dict[str, Any]) -> str: self.root_schema = schema self.root_composes_protocol_envelope = self.composes_protocol_envelope(schema) - arms = schema.get("oneOf") or schema.get("anyOf") or [] - if not arms: - arms = [schema] - if self.base == "BuildCreativeResponse" and len(arms) == 6: + branches = schema.get("oneOf") or schema.get("anyOf") or [] + if not branches: + branches = [schema] + if self.base == "BuildCreativeResponse" and len(branches) == 6: # Preserve the long-standing public numbering where - # Response1 is the simple success arm and Response2 is the - # error arm; append newer schema branches after those. - arms = [arms[index] for index in (0, 4, 1, 2, 3, 5)] - class_names = [f"{self.base}{index}" for index in range(1, len(arms) + 1)] + # Response1 is the simple success branch and Response2 is the + # error branch; append newer schema branches after those. + branches = [branches[index] for index in (0, 4, 1, 2, 3, 5)] + class_names = [f"{self.base}{index}" for index in range(1, len(branches) + 1)] response_classes = [ - self.emit_response_class(name, arm) for name, arm in zip(class_names, arms) + self.emit_response_class(name, branch) + for name, branch in zip(class_names, branches) ] if self.needs_protocol_envelope: self.add_import("from ..core.protocol_envelope import ProtocolEnvelope") @@ -5688,23 +6508,23 @@ def render(self, schema: dict[str, Any]) -> str: for relative, base in response_specs: target = OUTPUT_DIR / relative if not target.exists(): - print(f" {relative} not found (skipping response arms)") + print(f" {relative} not found (skipping response branches)") continue schema_rel = _schema_relative(relative) schema_path = SCHEMA_DIR / schema_rel if not schema_path.exists(): - print(f" {schema_rel} not found (skipping response arms)") + print(f" {schema_rel} not found (skipping response branches)") continue emitter = Emitter(relative, base, schema_rel) new_source = emitter.render(_load_schema(schema_rel)) if target.read_text() != new_source: target.write_text(new_source) fixed += 1 - print(f" {relative}: regenerated response arms from schema") + print(f" {relative}: regenerated response branches from schema") else: - print(f" {relative}: schema-derived response arms already current") + print(f" {relative}: schema-derived response branches already current") - print(f" ✓ Regenerated schema-derived response arms: {fixed}") + print(f" ✓ Regenerated schema-derived response branches: {fixed}") def fix_comply_controller_account_optional() -> None: @@ -5847,8 +6667,7 @@ def fix_verify_brand_claim_models() -> None: bulk_response = OUTPUT_DIR / "brand" / "verify_brand_claims_response.py" if request.exists() and "claim_type:" not in request.read_text(): - request.write_text( - """# generated by datamodel-codegen: + request.write_text("""# generated by datamodel-codegen: # filename: brand/verify_brand_claim_request.json from __future__ import annotations @@ -5880,13 +6699,11 @@ class VerifyBrandClaimRequest(AdcpVersionEnvelope): dict[str, Any], Field(description='Claim payload. Shape varies by claim_type.'), ] -""" - ) +""") print(" brand/verify_brand_claim_request.py: restored claim fields") if response.exists() and "VerifyBrandClaimSuccessResponse" not in response.read_text(): - response.write_text( - """# generated by datamodel-codegen: + response.write_text("""# generated by datamodel-codegen: # filename: brand/verify_brand_claim_response.json from __future__ import annotations @@ -5945,9 +6762,8 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope): VerifyBrandClaimResponse = VerifyBrandClaimSuccessResponse | VerifyBrandClaimErrorResponse -""" - ) - print(" brand/verify_brand_claim_response.py: restored response arms") +""") + print(" brand/verify_brand_claim_response.py: restored response branches") response_schema = SCHEMA_DIR / "brand" / "verify-brand-claim-response.json" if ( @@ -5955,8 +6771,7 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope): and response_schema.exists() and '"signed_response"' in response_schema.read_text() ): - response.write_text( - """# generated by datamodel-codegen: + response.write_text("""# generated by datamodel-codegen: # filename: brand/verify_brand_claim_response.json from __future__ import annotations @@ -6107,8 +6922,7 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope): VerifyBrandClaimResponse = VerifyBrandClaimSuccessResponse | VerifyBrandClaimErrorResponse -""" - ) +""") print(" brand/verify_brand_claim_response.py: restored signed response fields") bulk_response_schema = SCHEMA_DIR / "brand" / "verify-brand-claims-response.json" @@ -6117,8 +6931,7 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope): and bulk_response_schema.exists() and '"signed_response"' in bulk_response_schema.read_text() ): - bulk_response.write_text( - """# generated by datamodel-codegen: + bulk_response.write_text("""# generated by datamodel-codegen: # filename: brand/verify_brand_claims_response.json from __future__ import annotations @@ -6181,7 +6994,7 @@ class ResultEntry(RootModel[ResultEntry1 | ResultEntry2]): root: Annotated[ ResultEntry1 | ResultEntry2, Field( - description='One entry in `results[]`. Either a per-claim success (claim_type + status + optional details/context_note) or a per-claim error (error field only). Mirrors the single-target `verify_brand_claim` response success arm shape.' + description='One entry in `results[]`. Either a per-claim success (claim_type + status + optional details/context_note) or a per-claim error (error field only). Mirrors the single-target `verify_brand_claim` response success branch shape.' ), ] @@ -6301,8 +7114,7 @@ class VerifyBrandClaimsErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope): VerifyBrandClaimsResponse = VerifyBrandClaimsResponseBulk | VerifyBrandClaimsErrorResponse -""" - ) +""") print(" brand/verify_brand_claims_response.py: restored signed response fields") if bulk_response.exists(): @@ -6445,8 +7257,8 @@ def fix_list_creatives_format_reference_xor() -> None: branch models. All three refusals name ``oneOf``: that is the combinator the record's - schema states, and a document satisfying no arm — or both — failed it. - ``not`` is the arm-internal clause that makes the arms disjoint, not the + schema states, and a document satisfying no branch — or both — failed it. + ``not`` is the branch-internal clause that makes the branches disjoint, not the keyword the record was rejected by. """ @@ -6554,7 +7366,7 @@ def fix_creative_manifest_standalone_asset_coercion() -> None: if "_coerce_standalone_assets" in source: target.write_text(source) return - # Idempotent: an earlier pass (``enforce_root_required_groups``) may already + # Idempotent: an earlier pass (``enforce_root_constraints``) may already # have put ``model_validator`` on this line, and a literal prefix replace # appended it a second time on every regeneration. source = ensure_pydantic_import(source, "model_validator") @@ -6618,11 +7430,11 @@ def strip_extra_blank_lines_at_eof() -> None: def fix_compliance_task_completion_response_ref() -> None: - """Point the completion JSON Pointer at the restored success arm. + """Point the completion JSON Pointer at the restored success branch. datamodel-code-generator names ``create-media-buy-response#/oneOf/0`` as ``Field0``. ``restore_response_variant_aliases`` replaces that generated - module with stable semantic arm names, where the same schema is + module with stable semantic branch names, where the same schema is ``CreateMediaBuyResponse1``. """ target = OUTPUT_DIR / "compliance" / "task_completion_data.py" @@ -6635,7 +7447,7 @@ def fix_compliance_task_completion_response_ref() -> None: ) if fixed != source: target.write_text(fixed) - print(" compliance/task_completion_data.py: restored create-media-buy success arm") + print(" compliance/task_completion_data.py: restored create-media-buy success branch") def fix_product_fields_item_reference() -> None: @@ -6713,14 +7525,14 @@ def enum_members(module_source: str, class_name: str) -> list[tuple[str, str]]: def fix_audience_evidence_attestation_subject() -> None: - """Keep the audience-evidence attestation subject narrowed to its resource arm. + """Keep the audience-evidence attestation subject narrowed to its resource branch. The schema's allOf constrains ``attestation_refs[].subject`` to the ``resource`` variant for the evidence snapshot. Codegen instead emits three overlapping RootModel unions and applies another ``type`` discriminator around them, so every wrapper advertises ``brand``, ``agent``, and ``resource`` and Pydantic rejects the duplicate tags. - The generated merged resource arm has the required audience-evidence + The generated merged resource branch has the required audience-evidence resource type, content digest, namespace, and identifier. Its numeric suffix is an implementation detail that changes as schemas evolve. """ @@ -6753,17 +7565,17 @@ def fix_audience_evidence_attestation_subject() -> None: fixed = source[:class_start] + fixed_class + source[next_class + 1 :] if fixed != source: target.write_text(fixed) - print(" core/audience_evidence.py: narrowed attestation subject to resource arm") + print(" core/audience_evidence.py: narrowed attestation subject to resource branch") def fix_legacy_purchase_accepted_losses() -> None: """Restore coordinator-input constraints codegen cannot represent. The beta.5 schema expresses a non-empty enum array with ``oneOf`` plus a - negated empty-array arm. datamodel-code-generator interprets that negated - arm as an empty object model and emits ``list[AcceptedLoss] | + negated empty-array branch. datamodel-code-generator interprets that negated + branch as an empty object model and emits ``list[AcceptedLoss] | AcceptedLosses``. Besides accepting the wrong shape in the annotation, - Pydantic can raise a raw ``TypeError`` while trying the empty model arm. + Pydantic can raise a raw ``TypeError`` while trying the empty model branch. The protocol contract is simply a list, with length and enum validation. Codegen also drops ``uniqueItems``, ``contains``, and ``minProperties``; inject validators and JSON Schema metadata so this public SDK-local model @@ -6834,9 +7646,7 @@ def fix_legacy_purchase_accepted_losses() -> None: ) validator_marker = " def _accepted_losses_match_schema(" if validator_marker not in fixed and "field_validator" in fixed: - fixed = ( - fixed.rstrip() - + """ + fixed = fixed.rstrip() + """ @field_validator('selected_product_ids') @@ -6864,7 +7674,6 @@ def _accepted_losses_match_schema( raise ValueError('accepted_losses must include the required compatibility losses') return values """ - ) if fixed != source: target.write_text(fixed) print(" media_buy/legacy_purchase_continuation_input.py: narrowed accepted_losses") @@ -6910,8 +7719,8 @@ def preserve_request_signing_operation_strings() -> None: def enforce_change_term_runtime_constraints() -> None: """Preserve beta.9 change-right verifier constraints in Pydantic models. - datamodel-code-generator represents the constraint ``oneOf`` arms but - drops each arm's nested ``anyOf(required=...)`` rule, along with the + datamodel-code-generator represents the constraint ``oneOf`` branches but + drops each branch's nested ``anyOf(required=...)`` rule, along with the schema's ``x-adcp-validation`` cross-field assertions. Restore those checks so direct Python model construction cannot create a proposal that the released JSON Schema or seller resolver would reject. @@ -6995,9 +7804,7 @@ def _require_portable_bound(self) -> {class_name}: ) class_start = source.find("class MediaBuyChangeTerm(AdCPBaseModel):") if class_start >= 0 and "def _validate_constraint_action" not in source[class_start:]: - source = ( - source.rstrip() - + """ + source = source.rstrip() + """ @model_validator(mode='after') def _validate_constraint_action(self) -> MediaBuyChangeTerm: @@ -7017,9 +7824,7 @@ def _validate_constraint_action(self) -> MediaBuyChangeTerm: if action not in allowed.get(kind, set()): raise ValueError('constraint kind is incompatible with action') return self -""" - + "\n" - ) +""" + "\n" term_path.write_text(source) print(" media_buy/change_term.py: restored constraint/action compatibility") @@ -7032,9 +7837,7 @@ def _validate_constraint_action(self) -> MediaBuyChangeTerm: ) class_start = source.find("class CommercialTerms(AdCPBaseModel):") if class_start >= 0 and "def _validate_change_term_set" not in source[class_start:]: - source = ( - source.rstrip() - + """ + source = source.rstrip() + """ @model_validator(mode='after') def _validate_change_term_set(self) -> CommercialTerms: @@ -7083,9 +7886,7 @@ def _validate_change_term_set(self) -> CommercialTerms: ): raise ValueError('change-term earliest effective time exceeds latest time') return self -""" - + "\n" - ) +""" + "\n" terms_path.write_text(source) print(" media_buy/commercial_terms.py: restored change-term set invariants") @@ -7203,42 +8004,24 @@ def _write_manifest(observed: dict[str, list[str]], previous: dict[str, bool | s return 0 -_REQUIRED_GROUP_VALIDATOR = "_require_schema_required_group" - # Bases that declare no schema fields of their own. Any other base the # generated tree cannot resolve leaves the class's field set unknown, and an # unknown field set means the required-group rule is reported, not guessed. _FIELDLESS_GENERATED_BASES = {"AdCPBaseModel", "BaseModel", "RootModel"} -def _root_required_groups(schema: dict) -> tuple[str, tuple[tuple[str, ...], ...]] | None: - """Return the combinator a schema root declares and its required-field groups. +#: The package source root, where the hand-written bases a generated module +#: imports absolutely live. Only ``adcp.*`` resolves here: a third-party base +#: leaves the field set unknown, which is reported rather than guessed. +_PACKAGE_ROOT = REPO_ROOT / "src" - A root ``anyOf``/``oneOf`` whose every arm carries ``required`` states which - combinations of fields a document may omit. Arms also carry discriminating - ``properties`` and ``not`` clauses; those narrow an arm further and are not - part of its group. - The combinator comes back with the groups because it is the JSON Schema - keyword the emitted check enforces, and ``core/error.json`` requires every - ``issues[]`` entry to name one. Deriving it here is the only place it is - known: the message text does not distinguish the two, and a consumer reading - a bare ``value_error`` off the wire has nothing to put in ``keyword``. - """ - for combinator in ("anyOf", "oneOf"): - arms = schema.get(combinator) - if not isinstance(arms, list) or not arms: - continue - if not all(isinstance(arm, dict) and isinstance(arm.get("required"), list) for arm in arms): - continue - groups: list[tuple[str, ...]] = [] - for arm in arms: - names = tuple(name for name in arm["required"] if isinstance(name, str)) - if names and names not in groups: - groups.append(names) - if groups: - return combinator, tuple(groups) - return None +def _absolute_package(module: str) -> tuple[Path | None, list[str]]: + """Resolve an absolute ``adcp.*`` import to its package directory and tail.""" + parts = module.split(".") + if parts[0] != "adcp": + return None, [] + return _PACKAGE_ROOT, parts def _generated_import_targets( @@ -7248,15 +8031,26 @@ def _generated_import_targets( The second element is the symbol imported from that file, or ``None`` when the name is the module itself (``from ..core import account_ref``). + + A generated module reaches the hand-written bases absolutely -- + ``from adcp.types.base import AdcpRequest`` -- so those resolve against the + package source root, which is never the staged tree. Without them every + request root inheriting ``AdcpRequest`` has an unknown field set, and an + unknown field set is reported rather than constrained. """ targets: dict[str, tuple[Path, str | None]] = {} for node in tree.body: - if not isinstance(node, ast.ImportFrom) or node.level < 1: + if not isinstance(node, ast.ImportFrom): continue - package = py_path.parent - for _ in range(node.level - 1): - package = package.parent - module_parts = (node.module or "").split(".") + if node.level < 1: + package, module_parts = _absolute_package(node.module or "") + if package is None: + continue + else: + package = py_path.parent + for _ in range(node.level - 1): + package = package.parent + module_parts = (node.module or "").split(".") for alias in node.names: as_module = package.joinpath(*module_parts, alias.name).with_suffix(".py") if as_module.is_file(): @@ -7347,28 +8141,140 @@ def _generated_fields( def _root_generated_class(content: str, schema: dict) -> str | None: """Return the single class datamodel-code-generator emits for a schema root. - The root class takes its name from the schema title, up to case: the - generator title-cases an acronym, so "SI Send Message Request" becomes - ``SiSendMessageRequest``. Match the title-derived name case-insensitively - and return the class as the module spells it. + The root class takes its name from the schema title, and + :func:`_schema_root_class_name` derives that name by asking the generator, + so the two agree on casing — "SI Send Message Request" derives + ``SiSendMessageRequest``, which is what the module declares. The match here + is still case-insensitive, now only so that a generator whose naming rule + moves is caught by the guard on the derivation + (``test_generated_hierarchy.py``) rather than by this function silently + returning ``None`` for every root. When no class carries that name the root did not generate one flat model — - the generator split it into per-arm classes behind a union alias — and a - group derived from one arm does not constrain another. When two classes + the generator split it into per-branch classes behind a union alias — and a + group derived from one branch does not constrain another. When two classes carry it, the name does not identify the root. + + This is the FLAT class only, and deliberately: its name is also the root's + name, which is what branch class names are derived from. A reader that wants + "whichever class declares the root's properties" on an already-split tree + wants :func:`_root_property_class`, which answers the shape — asking this + one to fall back to the shape made it return ``CreateMediaBuyRequestShape`` + as the root's name and produced branches called + ``CreateMediaBuyRequestShapeExplicitPackagesWithFixedAllocation``. """ + return _named_class(content, _schema_root_class_name(schema)) + + +def _schema_root_class_name(schema: dict) -> str | None: + """The class name the generator derives from a schema title, or ``None``.""" title = schema.get("title") if not isinstance(title, str) or not title.strip(): return None - wanted = _schema_title_to_class_name(title).lower() + return generated_root_class_name(title) + + +def _named_class(content: str, wanted: str | None) -> str | None: + """The one class whose name matches, up to case, or ``None``.""" + if wanted is None: + return None declared = { match.group(1) for match in re.finditer(r"^class (\w+)\b", content, re.MULTILINE) - if match.group(1).lower() == wanted + if match.group(1).lower() == wanted.lower() } - if len(declared) != 1: + return declared.pop() if len(declared) == 1 else None + + +def _root_property_class(content: str, schema: dict) -> str | None: + """Whichever class declares the root's merged properties. + + The flat class before the emitter runs, and ``<Root>Shape`` after: the + root's own name is bound to a union then, and its properties live on the + shape the branches share. Both are the same declaration, which is why a reader + of a declaration may take either — and why a reader of the root's NAME may + not (see :func:`_root_generated_class`). + """ + root = _schema_root_class_name(schema) + if root is None: return None - return declared.pop() + return _named_class(content, root) or _named_class(content, f"{root}Shape") + + +def generated_module_for(relative_path: Path) -> Path: + """The generated module a schema path maps to.""" + module = relative_path.with_suffix(".py") + return OUTPUT_DIR.joinpath(*(part.replace("-", "_") for part in module.parts)) + + +def _root_declared_properties(schema: dict) -> dict[str, Any]: + """Every property the root declares, wherever in the root it declares it. + + A root may carry no ``properties`` of its own and declare all of them inside + its combinator's branches or its ``allOf`` members, and + ``datamodel-code-generator`` MERGES those onto one flat class. Two roots in + the bundle are exactly that shape: ``brand/search-brands-response`` declares + ``brands`` and ``errors`` only in its ``oneOf`` branches, and + ``brand/verify-brand-claim-request`` declares ``claim_type`` and ``claim`` + the same way. Both generate one class carrying those fields. + + Reading only ``schema["properties"]`` declined for both, on the reasoning + that a root with no properties has no merged shape and so its branches ARE + the types. The premise does not entail the conclusion: the properties live + in the branches and the class is still flat. That decline was a skip lane + spelled ``None``, and it dropped the two roots from the compiled pass. + """ + merged: dict[str, Any] = {} + for source in ( + schema, + *(schema.get("allOf") or []), + *(schema.get("anyOf") or []), + *(schema.get("oneOf") or []), + ): + if not isinstance(source, dict): + continue + declared = source.get("properties") + if isinstance(declared, dict): + for name, sub in declared.items(): + merged.setdefault(name, sub) + return merged + + +def flat_root_shape(schema: dict, relative_path: Path) -> DeclaredShape | None: + """What the ONE class over a schema root declares, or ``None`` if there is none. + + A flat root is one the generator rendered as a single class holding the + root's merged ``properties``. That is the shape whose root constraint + content was dropped, so it is the whole jurisdiction of the constraint + reader -- and the one place the question "which declaration exists" has an + answer other than a guess. + + ``None`` means the root is not flat, each for a named reason: the module was + not generated; no single class carries the root's title, because the + generator split the root into per-branch classes behind a union alias; a base + outside the package leaves the field set unknown; the root declares no + properties, so there is no merged shape and the branches ARE the types; or the + class does not carry a property the root declares, which is a dropped field + and belongs to whoever generated it rather than to a constraint pass. + """ + properties = _root_declared_properties(schema) + if not properties: + return None + module_path = generated_module_for(relative_path) + if not module_path.is_file(): + return None + # The DECLARATION, which after the emitter has run lives on the shape. + class_name = _root_property_class(module_path.read_text(), schema) + if class_name is None: + return None + fields = _generated_fields(module_path, class_name) + if fields is None or any(name not in fields for name in properties): + return None + return DeclaredShape( + fields=fields, + nested={name: DeclaredShape.from_schema(sub) for name, sub in properties.items()}, + closed=schema.get("additionalProperties") is False, + ) def _ensure_model_validator_import(content: str) -> str: @@ -7387,6 +8293,60 @@ def _ensure_model_validator_import(content: str) -> str: ) +def _class_declares(content: str, class_name: str, attribute: str) -> bool: + """Whether ``class_name`` itself carries an annotated ``attribute``. + + The sibling :func:`_class_defines` looks for a ``def``; a ClassVar is an + ``AnnAssign`` and that test misses it. Class-scoped for the same reason: + the split copies a flat class's members onto every branch, so a module-wide + string test answers a different question than asking the class. + """ + for node in ast.parse(content).body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + return any( + isinstance(child, ast.AnnAssign) + and isinstance(child.target, ast.Name) + and child.target.id == attribute + for child in node.body + ) + return False + + +def _without_a_declared_combinator_fields(content: str, class_name: str, block: str) -> str: + """``block`` minus its ClassVar when ``class_name`` already declares one. + + Both attach sites append a rendered validator block, and a split root goes + through both -- so ``create_media_buy_request.py`` carried two identical + ``_COMBINATOR_FIELDS`` lines in one class body. Harmless at runtime, since + the value is identical and the second assignment wins, and wrong to emit. + """ + if f"{COMBINATOR_FIELDS}:" not in block or not _class_declares( + content, class_name, COMBINATOR_FIELDS + ): + return block + return "\n".join(line for line in block.splitlines() if f"{COMBINATOR_FIELDS}:" not in line) + + +def _class_defines(content: str, class_name: str, function: str) -> bool: + """Whether ``class_name`` itself defines ``function``. + + Class-scoped on purpose. A module-wide ``f"def {name}" in content`` test is + what made the first version of this fix a no-op on its own motivating case: + the split COPIES the flat class's compiled validator onto every branch, so + ``creative_manifest`` already contained the string twice -- on + ``CreativeManifestNamedFormatManifest`` and on + ``CreativeManifestCanonicalFormatManifest`` -- while the shared base the + branches subclass had none. Asking the module answers a different question + than asking the class. + """ + for node in ast.parse(content).body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + return any( + isinstance(child, ast.FunctionDef) and child.name == function for child in node.body + ) + return False + + def _append_to_class_block(content: str, class_name: str, block: str) -> str: """Insert ``block`` at the end of ``class_name``'s body. @@ -7405,31 +8365,74 @@ class body is the end of its AST node, not the start of the next class. return content -def enforce_root_required_groups() -> None: - """Restore root-level ``anyOf``/``oneOf`` required-group constraints. +#: The one hook a split root's base mixes in. One implementation, not one copy +#: per root -- two bugs were found in that hook while it was still a single copy. +BRANCH_DISPATCH_IMPORT = "from adcp.types._branch_dispatch import BranchDispatchMixin" + + +def _ensure_branch_dispatch_import(content: str) -> str: + """Give a split module the shared dispatch mixin, once.""" + return _ensure_module_import(content, BRANCH_DISPATCH_IMPORT) - datamodel-code-generator renders a schema root's ``properties`` and drops a - root-level ``anyOf``/``oneOf`` over required-field groups, so the generated - class accepts documents its own schema rejects — a ``create_media_buy`` - request with no packages, no budget and no proposal validates. - Emit a ``model_validator`` requiring at least one group. That is the whole - constraint for ``anyOf``. For ``oneOf`` it is the part the schema states in - ``required``: cardinality is not derivable here, because those arms are - distinguished by ``properties`` discriminators and several schemas give two - arms the same ``required`` set, which no "exactly one required group" - reading satisfies. +def _ensure_combinator_fields_import(content: str, block: str) -> str: + """``ClassVar`` for the block that declares the combinator's fields. - The refusal names the combinator the groups came from — ``anyOf`` or - ``oneOf``, whichever the root declared — so the pydantic error ``type`` is - the keyword ``core/error.json`` requires in ``issues[].keyword``. The - partial ``oneOf`` enforcement still names ``oneOf``: the document failed the - root ``oneOf``, and which of its obligations went unmet is what the message - says, not what the keyword says. + Only when the block actually declares it. Measured: 429 modules carry a + compiled validator and 86 of them have a combinator, so the annotation is + needed in a sixth of them -- and 18 already import the name. Keying on the + rendered block rather than on the constraints keeps the one condition in the + renderer that decides whether the attribute exists at all. """ + if f"{COMBINATOR_FIELDS}:" not in block: + return content + return _ensure_typing_import(content, "ClassVar") + + +def _ensure_conditions_import(content: str) -> str: + """Give a module the vocabulary import, once.""" + return _ensure_module_import(content, CONDITIONS_IMPORT) + + +def enforce_root_constraints() -> None: + """Compile what a schema root states beyond its merged ``properties``. + + ``datamodel-code-generator`` renders a root's ``properties`` and drops the + rest: a root ``anyOf``/``oneOf`` over overlay branches, a root + ``if``/``then``/``else``, root ``dependencies``, a root ``not``. Each + dropped keyword is a document the generated model accepts and the canonical + schema refuses -- a ``create_media_buy`` request with packages that carry no + budget, a ``get_geo_place_resolution`` request carrying both ``q`` and + ``value``, a ``list_account_changes`` response that is both completed and + failed. + + ``scripts/schema_constraints.py`` reads the whole of that content into one + record and ``scripts/compile_conditions.py`` compiles it onto the class. + What this pass replaced read each branch's ``required`` NAMES and discarded + every other keyword the branch carried, while claiming in its own docstring + that the names "are the whole constraint for ``anyOf``" -- which was false + for the very file that reproduced the bug. + + Three populations, and every root falls in exactly one: + + * A FLAT root -- one generated class over the root's merged properties -- + gets the compiled validator. That is this pass's jurisdiction, because the + flat class is the shape whose constraint content was dropped. + * A root the generator already split into per-branch classes states its branches + as types. Its cross-branch exclusivity is carried by canonical wire + validation, unchanged by this pass and named in the design's coverage + table rather than absorbed here. + * A root whose content is entirely restatements of what the class declares + gets no validator, because there is nothing left to check. + + A keyword the grammar cannot compile raises ``UnexpressibleKeywordError`` + and aborts generation. There is no skip lane. + """ + resolve = bundle_document_resolver(SCHEMA_DIR) emitted = 0 already = 0 - skipped: list[str] = [] + already_typed = 0 + nothing_to_check = 0 for schema_file in sorted(SCHEMA_DIR.rglob("*.json")): relative_path = schema_file.relative_to(SCHEMA_DIR) @@ -7437,68 +8440,527 @@ class accepts documents its own schema rejects — a ``create_media_buy`` schema = json.loads(schema_file.read_text()) except (json.JSONDecodeError, OSError): continue - if not isinstance(schema, dict): + if not isinstance(schema, dict) or not root_carries_constraints(schema): continue - declared = _root_required_groups(schema) - if declared is None: - continue - keyword, groups = declared - module_path = relative_path.with_suffix(".py") - py_path = OUTPUT_DIR.joinpath(*(part.replace("-", "_") for part in module_path.parts)) - if not py_path.is_file(): + shape = flat_root_shape(schema, relative_path) + if shape is None: + already_typed += 1 continue + constraints = read_root_constraints( + schema, + shape, + branches_render_as_classes=False, + schema_file=str(relative_path), + resolve_document=resolve, + ) + py_path = generated_module_for(relative_path) content = py_path.read_text() class_name = _root_generated_class(content, schema) - if class_name is None: - skipped.append(f"{relative_path}: no single root model class") + if class_name is None: # pragma: no cover - flat_root_shape just found one continue - if f"def {_REQUIRED_GROUP_VALIDATOR}" in content: + if f"def {ROOT_CONSTRAINT_VALIDATOR}" in content: already += 1 continue - fields = _generated_fields(py_path, class_name) - if fields is None: - skipped.append(f"{relative_path}: {class_name} inherits an unresolved base") + block = render_root_validator(constraints, class_name, str(relative_path)) + if not block: + nothing_to_check += 1 continue - if any(name not in fields for group in groups for name in group): - skipped.append(f"{relative_path}: {class_name} is not one flat model over the groups") - continue - attribute_groups = [tuple(fields[name] for name in group) for group in groups] - - rendered = ", ".join(repr(group) for group in attribute_groups) - readable = " | ".join("+".join(group) for group in attribute_groups) - refusal = schema_keyword_raise( - keyword, - f"{class_name} requires at least one of these field groups: {readable}", - ) - validator = f""" - @model_validator(mode='after') - def {_REQUIRED_GROUP_VALIDATOR}(self) -> {class_name}: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in ({rendered},): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. -{refusal} -""" - content = _append_to_class_block(content, class_name, validator) + block = _without_a_declared_combinator_fields(content, class_name, block) + content = _append_to_class_block(content, class_name, block) content = _ensure_model_validator_import(content) + content = _ensure_conditions_import(content) + content = _ensure_combinator_fields_import(content, block) + # The refusal names the root combinator (#1435), so the module needs the + # error type that carries a keyword. Dropped when this pass replaced the + # required-names extractor the import had lived in. content = ensure_pydantic_core_import(content, "PydanticCustomError") py_path.write_text(content) emitted += 1 - print(f" Emitted {emitted} root required-group validator(s)") + print(f" Emitted {emitted} compiled root-constraint validator(s)") if already: print(f" {already} class(es) already carried one") - for note in skipped: - print(f" skipped {note}") + print(f" {already_typed} root(s) state their branches as types already (not flat)") + print(f" {nothing_to_check} flat root(s) state only restatements of their own class") + + +class _GeneratedIndex(NamedTuple): + """Where every generated class is defined, and how each module names the others. + + A base class arrives in three spellings and the inheritance chain cannot be + followed without all three: ``class X(Y)`` with ``Y`` in the same module, + ``class X(Y)`` with ``Y`` from ``from ..core.y import Y``, and + ``class X(y_1.Y)`` with ``y_1`` from ``from ..core import y as y_1``. + """ + + #: module path -> {class name: its ClassDef} + classes: dict[Path, dict[str, ast.ClassDef]] + #: module path -> {local name: (defining module, class name)} + imported: dict[Path, dict[str, tuple[Path, str]]] + #: module path -> {local alias: the module it names} + aliases: dict[Path, dict[str, Path]] + + +def _index_generated_classes() -> _GeneratedIndex: + """Index the generated tree so a base-class expression can be resolved.""" + index = _GeneratedIndex({}, {}, {}) + for py_file in sorted(OUTPUT_DIR.rglob("*.py")): + try: + tree = ast.parse(py_file.read_text()) + except SyntaxError: + continue + index.classes[py_file] = {n.name: n for n in tree.body if isinstance(n, ast.ClassDef)} + index.imported[py_file] = {} + index.aliases[py_file] = {} + for node in tree.body: + if not isinstance(node, ast.ImportFrom): + continue + if node.level == 0: + if not (node.module or "").startswith("adcp.types.domains"): + continue + tail = node.module.removeprefix("adcp.types.domains").strip(".") + package = OUTPUT_DIR / Path(*tail.split(".")) if tail else OUTPUT_DIR + else: + # ``level`` counts leading dots: 1 is this module's own package. + package = py_file.parent + for _ in range(node.level - 1): + package = package.parent + if node.module: + package = package / Path(*node.module.split(".")) + as_module_file = package.with_suffix(".py") + for alias in node.names: + local = alias.asname or alias.name + sibling = package / f"{alias.name}.py" + if sibling.exists(): + index.aliases[py_file][local] = sibling + elif as_module_file.exists(): + index.imported[py_file][local] = (as_module_file, alias.name) + return index + + +def _resolve_base(base: ast.expr, py_file: Path, index: _GeneratedIndex) -> tuple[Path, str] | None: + """Resolve one base-class expression to the module and name that define it.""" + if isinstance(base, ast.Name): + if base.id in index.classes.get(py_file, {}): + return (py_file, base.id) + return index.imported.get(py_file, {}).get(base.id) + if isinstance(base, ast.Attribute) and isinstance(base.value, ast.Name): + module = index.aliases.get(py_file, {}).get(base.value.id) + if module is not None: + return (module, base.attr) + return None + + +def _is_request_module(py_file: Path, index: _GeneratedIndex) -> bool: + """Whether this module's generated classes are a REQUEST the buyer sends. + + Read from the bases the generator wrote -- ``AdcpRequest`` against + ``AdcpResponse`` -- rather than from the file name, so the partition is the + generator's own. + + It matters because a default on a REQUEST field is the generated CLIENT + asserting something about the buyer's intent, and a default on a RESPONSE + field is the SELLER stating something it is the authority on. + ``core/opportunity-context.json`` says of ``status`` that "omission means no + status update and MUST NOT reopen or close the opportunity implicitly", and + ``media-buy/accept-proposal-request.json`` that "Success infers closed with + accepted_with_seller when status is omitted" -- the seller infers, so the + request must not assert. Neither sentence says anything about a seller + stating ``proposal_status`` on a response it owns. + """ + return any( + isinstance(base, ast.Name) and base.id == "AdcpRequest" + for node in index.classes.get(py_file, {}).values() + for base in node.bases + ) + + +def _guard_firing_values(expression: ast.AST) -> set[tuple[str, object]]: + """``(name, value)`` for each ``self`` member a GUARD tests for equality. + + The compiler renders a schema ``if``/``then``/``else`` as a conditional + expression, so the ``if``'s content is exactly ``IfExp.test`` and the + requirement it imposes is the body. Only the test matters here: a default + that equals a value the test compares against FIRES the conditional, while a + default sitting in the body SATISFIES a requirement and is the behaviour the + ergonomic default exists for. + + Measured on the two shapes this separates: + + core/opportunity-context.json test reads status == 'closed', body + requires close_reason -> status's + 'closed' default fires it + core/reporting-resource.json test reads kind == 'manifest', body + requires manifest_version -> that + default satisfies, never fires + + A nested subject is skipped: ``_c.descend(package, 'x', ...)`` grades a + member of another class, whose own fields this pass is not looking at. + """ + found: set[tuple[str, object]] = set() + for node in ast.walk(expression): + if not isinstance(node, ast.IfExp): + continue + for call in ast.walk(node.test): + if not ( + isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and isinstance(call.func.value, ast.Name) + and call.func.value.id == "_c" + and len(call.args) == 3 + and isinstance(call.args[0], ast.Name) + and call.args[0].id == "self" + and isinstance(call.args[1], ast.Constant) + and isinstance(call.args[2], ast.Lambda) + ): + continue + body = call.args[2].body + if ( + isinstance(body, ast.Compare) + and len(body.ops) == 1 + and isinstance(body.ops[0], ast.Eq) + and len(body.comparators) == 1 + and isinstance(body.comparators[0], ast.Constant) + ): + found.add((call.args[1].value, body.comparators[0].value)) + return found + + +def _conjunct_guard_values( + py_file: Path, class_name: str, index: _GeneratedIndex +) -> set[tuple[str, object]]: + """``(name, value)`` pairs any conjunct GRADING this class fires on. + + Walks the base chain rather than the class alone, because the conjunct that + grades a subclass is usually declared on the base: the generator renders a + schema that PINS a member as a subclass narrowing that member, and the + compiled validator stays on the base (salesagent-8qela.50). + """ + firing: set[tuple[str, object]] = set() + seen: set[tuple[Path, str]] = set() + pending = [(py_file, class_name)] + while pending: + key = pending.pop() + if key in seen: + continue + seen.add(key) + module, name = key + node = index.classes.get(module, {}).get(name) + if node is None: + continue + for statement in node.body: + if ( + isinstance(statement, ast.FunctionDef) + and statement.name == ROOT_CONSTRAINT_VALIDATOR + ): + firing |= _guard_firing_values(statement) + for base in node.bases: + resolved = _resolve_base(base, module, index) + if resolved is not None: + pending.append(resolved) + return firing + + +def _widen_literal_annotation(annotation: ast.expr) -> ast.expr: + """``Literal[X]`` -> ``Literal[X] | None``, inside ``Annotated`` when present.""" + if ( + isinstance(annotation, ast.Subscript) + and _subscript_base_name(annotation) == "Annotated" + and isinstance(annotation.slice, ast.Tuple) + and annotation.slice.elts + ): + inner = annotation.slice.elts[0] + annotation.slice.elts[0] = ast.BinOp(left=inner, op=ast.BitOr(), right=ast.Constant(None)) + return annotation + return ast.BinOp(left=annotation, op=ast.BitOr(), right=ast.Constant(None)) + + +def widen_conjunct_read_literal_defaults() -> None: + """Stop a class STATING a tag that a root conjunct grading it READS. + + ``inject_literal_discriminator_defaults`` gives a single-value ``Literal`` + field that value as a default so an adopter need not type the tag. Where a + root conjunct READS that field the default stops being ergonomic and becomes + an assertion: the class emits the tag on every document, the conjunct grades + the document, and a conditional fires on a value the caller never sent. + + Measured instance. ``media-buy/accept-proposal-request.json`` pins its + opportunity's ``status`` to ``closed``, so the generator emits + ``Opportunity(OpportunityContext)`` carrying ``status: Literal['closed'] = + 'closed'``; ``core/opportunity-context.json`` states that a ``closed`` status + requires ``close_reason``; and the model therefore REFUSED + ``{"opportunity_id": "A"}`` -- a document its own bundled schema accepts, and + the one that schema's description calls the normal case: "Success infers + closed with accepted_with_seller when status is omitted", and of ``status``, + "omission means no status update and MUST NOT reopen or close the + opportunity implicitly". The SELLER infers the closure, so a generated client + must not assert it (salesagent-8qela.50). + + The default is WIDENED, not dropped. Dropping it makes the field required, + which forbids the omission the spec requires to stay legal; + ``Literal['closed'] | None = None`` keeps it optional and emits nothing -- + the transformation ``fix_canceled_literal_defaults`` and + ``fix_unchanged_literal_defaults`` already perform for fields they name one + by one. This pass names none. Its population is DERIVED from the compiled + conjuncts, which is why it runs after ``enforce_root_constraints`` instead of + teaching ``inject_literal_discriminator_defaults`` to predict them. + """ + index = _index_generated_classes() + widened: list[str] = [] + touched = 0 + + for py_file in sorted(index.classes): + if not _is_request_module(py_file, index): + continue + lines = py_file.read_text().split("\n") + edits: list[tuple[int, int, str]] = [] + for class_name, node in index.classes[py_file].items(): + firing = _conjunct_guard_values(py_file, class_name, index) + if not firing: + continue + for statement in node.body: + if not isinstance(statement, ast.AnnAssign): + continue + if not isinstance(statement.target, ast.Name): + continue + tag = _extract_single_literal_value(statement.annotation) + if tag is None: + continue + if not (isinstance(statement.value, ast.Constant) and statement.value.value == tag): + continue # no default, or a default that is not the tag itself + if (statement.target.id, tag) not in firing: + continue # the default does not fire a conditional on this class + replacement = ast.AnnAssign( + target=statement.target, + annotation=_widen_literal_annotation(statement.annotation), + value=ast.Constant(None), + simple=1, + ) + first = lines[statement.lineno - 1] + indent = " " * (len(first) - len(first.lstrip())) + edits.append( + (statement.lineno, statement.end_lineno, indent + ast.unparse(replacement)) + ) + widened.append( + f"{py_file.relative_to(OUTPUT_DIR)}:{class_name}.{statement.target.id}" + ) + if not edits: + continue + for start, end, replacement in sorted(edits, key=lambda edit: -edit[0]): + lines[start - 1 : end] = [replacement] + py_file.write_text("\n".join(lines)) + touched += 1 + + for entry in sorted(widened): + print(f" widened {entry}") + print( + f" ✓ Widened {len(widened)} conjunct-read Literal default(s) to None " + f"across {touched} file(s)" + ) + + +def _ensure_typing_import(content: str, *names: str) -> str: + """Give a module a ``typing`` import for ``names``, extending or inserting.""" + try: + return add_to_import(content, "typing", *names) + except RuntimeError: + statement = f"from typing import {', '.join(sorted(names))}\n" + future = "from __future__ import annotations\n\n" + if future in content: + return content.replace(future, future + statement, 1) + return statement + content + + +def emit_branch_classes() -> None: + """Render a partitioning root's branches as one class each, behind a union alias. + + Where a root's branches partition the document space -- every branch requires + something, and the branches are provably pairwise disjoint under the root's own + conjuncts -- the branches are not a rule over one class. They are distinct + types, and the flattening is what turned them into a rule. So the flat + class is replaced by one class per branch, the root's public name becomes a + union over them, and the combination the old validator could only refuse at + validation time has no spelling that type-checks. + + Runs late, after every pass that edits a class by name, so each branch's class + carries what those passes put on the flat class. ``enforce_root_constraints`` + deliberately leaves these roots alone: a compiled check over the whole + combinator, copied onto every branch, would re-ask a question the branch's own + shape has already answered. + + An branch's content that its field set cannot carry -- a const one level down, + an overlay on an array's items -- stays a compiled check on that branch's own + class. The refusal then still arrives from the class the caller named. + """ + resolve = bundle_document_resolver(SCHEMA_DIR) + emitted = 0 + branches = 0 + repointed = 0 + + for schema_file in sorted(SCHEMA_DIR.rglob("*.json")): + relative_path = schema_file.relative_to(SCHEMA_DIR) + try: + schema = json.loads(schema_file.read_text()) + except (json.JSONDecodeError, OSError): + continue + if not isinstance(schema, dict) or not root_carries_constraints(schema): + continue + shape = flat_root_shape(schema, relative_path) + if shape is None: + continue + constraints = read_root_constraints( + schema, + shape, + branches_render_as_classes=False, + schema_file=str(relative_path), + resolve_document=resolve, + ) + if not partitions_by_requiredness(constraints): + continue + + py_path = generated_module_for(relative_path) + content = py_path.read_text() + class_name = _root_generated_class(content, schema) + if class_name is None: + continue + fields = frozenset(shape.fields.values()) + rewritten = compose_branch_module( + content, class_name, constraints, str(relative_path), fields + ) + if rewritten is None: + continue + py_path.write_text(rewritten) + emitted += 1 + branches += len(plan_branches(constraints, class_name, str(relative_path), fields)) + repointed += _repoint_subclasses_across_the_tree(class_name) + + print(f" Split {emitted} partitioning root(s) into {branches} branch class(es)") + if repointed: + print(f" Repointed {repointed} subclass module(s) onto a split root's shared base") + + +def compose_branch_module( + content: str, + class_name: str, + constraints: RootConstraints, + schema_file: str, + fields: frozenset[str], +) -> str | None: + """The split module plus the imports its own output needs. + + Separate from the tree walk because it is the whole of what the emitter + produces for one root, and so is what a test can run: the output has to + IMPORT, and an import the split forgets is a module that fails at import + rather than a test that fails on a string. + """ + plans = plan_branches(constraints, class_name, schema_file, fields) + rewritten = split_into_branch_classes(content, class_name, constraints, schema_file, fields) + if rewritten is None: + return None + + # The root's compiled constraint, on the SHARED BASE the branches subclass. + # + # ``enforce_root_constraints`` skips a split root, because a compiled check + # copied onto every branch would re-ask what the branch's own shape already + # answers. That is true of the BRANCHES and says nothing about a SUBCLASS of + # the root, which is what the canonical layer and every adopter build -- + # ``CanonicalBoundaryModel``'s own docstring says inheritance is the point of + # that layer. Measured on this tree: the root refuses a document satisfying + # no branch by ``type='oneOf'``, and a bare ``class Sub(Root): pass`` ACCEPTED + # it, because the refusal lives in the root's core schema (the union plus its + # wrapping validator) and a subclass is handed its own flat core schema + # instead. 11 of the 12 constrained roots lost it that way; the twelfth only + # looked safe because all its branches require the same keys, so ordinary + # field requiredness refused for an unrelated reason. + # + # An ``after`` validator IS inherited, whatever the core schema is assembled + # from, which is how upstream enforced this before the split and how the flat + # roots still do (``forecast_range`` and five peers: root and bare subclass + # both refuse by the combinator). ``_conditions``'s note that "a model + # validator on the shared BASE cannot supply it: field validation fails first" + # is about a document that fails INSIDE a branch -- on the root the union + # refuses first and this validator never runs, leaving the root's behaviour + # exactly as it is. On a subclass field validation SUCCEEDS, which is the very + # reason it over-accepted, so the validator runs and the constraint is back. + # + # It goes on the base and not on the branches, which is also what the original + # objection asked for: a branch satisfying its own required set satisfies "at + # least one group" anyway, so inheriting it costs the branches nothing. + root_validator = render_root_validator(constraints, class_name, schema_file) + if root_validator and not _class_defines(rewritten, class_name, ROOT_CONSTRAINT_VALIDATOR): + root_validator = _without_a_declared_combinator_fields( + rewritten, class_name, root_validator + ) + appended = _append_to_class_block(rewritten, class_name, root_validator) + if appended == rewritten: + raise RuntimeError( + f"{schema_file}: the split root {class_name} is not a class in the " + "composed module, so its compiled constraint has nowhere to live" + ) + rewritten = appended + rewritten = _ensure_combinator_fields_import(rewritten, root_validator) + rewritten = _ensure_model_validator_import(rewritten) + rewritten = ensure_pydantic_core_import(rewritten, "PydanticCustomError") + + banned = any(plan.absent or plan.absent_nullable for plan in plans) + # The union carries a wrapping validator that names the root combinator on a + # refusal, so every split module needs Annotated, WrapValidator and the + # conditions vocabulary. + rewritten = _ensure_typing_import(rewritten, "Annotated", "TypeAlias") + rewritten = ensure_pydantic_import(rewritten, "WrapValidator") + rewritten = _ensure_branch_dispatch_import(rewritten) + rewritten = _ensure_conditions_import(rewritten) + # Every split base declares ``_ROOT_REQUIRED``, unconditionally, so the + # annotation is needed whether or not a branch forbids a key. The ``banned`` + # branch below also asks for it; asking here is what makes it independent of + # whether this bundle happens to have a root with a forbidden key. + rewritten = _ensure_typing_import(rewritten, "ClassVar") + if any(plan.literals for plan in plans): + rewritten = _ensure_typing_import(rewritten, "Literal") + # No ``TypeAdapter`` import: the only thing that used it was the module-level + # ``<Root>BranchesAdapter``, which ``branch_classes.py`` no longer emits (see the + # note there). Ensuring it now would leave an unused import in every split + # module. + if banned: + rewritten = _ensure_typing_import(rewritten, "Any", "ClassVar") + if banned or any(_residual_is_checked(plan) for plan in plans): + rewritten = _ensure_model_validator_import(rewritten) + rewritten = _ensure_conditions_import(rewritten) + if any(_residual_is_checked(plan) for plan in plans): + # The per-branch refusal names the root combinator, so the branch's module + # needs the error type that carries a keyword. + rewritten = ensure_pydantic_core_import(rewritten, "PydanticCustomError") + return rewritten + + +def _repoint_subclasses_across_the_tree(flat_class: str) -> int: + """Point every generated subclass of a split root at the root's base. + + A union is not a base, so a class that subclassed the flat root has to name + the shared base instead. Measured on the pin: two modules do + (``Creative(CreativeAsset)``, ``CreativeRepresentation(CreativeManifest)``), + and nothing subclasses either of those, so the rewrite does not recurse. + """ + changed = 0 + for module in sorted(OUTPUT_DIR.rglob("*.py")): + content = module.read_text() + if f"({flat_class})" not in content and f"({flat_class}," not in content: + continue + rewritten = repoint_subclasses_of(content, flat_class) + if rewritten is None or rewritten == content: + continue + module.write_text(rewritten) + changed += 1 + return changed + + +def _residual_is_checked(plan: BranchPlan) -> bool: + """Whether this branch's class carries a compiled check of its own.""" + return compile_condition(plan.residual) != "True" _ADCP_REQUEST_MARKER = "AdcpRequest" @@ -7550,7 +9012,7 @@ def insert_task_message_markers() -> None: messages = task_message_lattice.task_registry_messages(SCHEMA_DIR) modules = task_message_lattice.generated_module_by_schema(OUTPUT_DIR) - # (module, class) -> marker. A response arm shared by several tasks is named more + # (module, class) -> marker. A response branch shared by several tasks is named more # than once; the marker is the same, so the map de-duplicates it. wanted: dict[tuple[Path, str], str] = {} for task, kind, schema_rel in messages: @@ -7566,7 +9028,7 @@ def insert_task_message_markers() -> None: marker = _ADCP_REQUEST_MARKER if kind == "request" else _ADCP_RESPONSE_MARKER tree = ast.parse(module.read_text()) for class_name in task_message_lattice.root_class_names( - schema_rel, module, tree, task_message_lattice.mangle_schema_title(title) + schema_rel, module, tree, generated_root_class_name(title) ): previous = wanted.get((module, class_name)) if previous is not None and previous != marker: @@ -7637,7 +9099,7 @@ def main(argv: list[str] | None = None) -> int: fix_typed_additional_properties, fix_deprecated_rootmodel_fields, fix_constr_type_annotations, - fix_postal_union_arm_order, + fix_postal_union_branch_order, fix_postal_country_system_pairing, fix_beta3_adagents_renderer_constraints, unwrap_rootmodel_unions, @@ -7668,13 +9130,12 @@ def main(argv: list[str] | None = None) -> int: fix_canceled_literal_defaults, fix_unchanged_literal_defaults, fix_optional_boolean_literal_defaults, - enforce_root_required_groups, + enforce_root_constraints, fix_reporting_request_selectors, fix_reporting_capability_defaults, fix_protocol_envelope_status_default, fix_trusted_match_runtime_validators, fix_beta3_secure_url_constraints, - fix_beta3_package_request_constraints, restore_trusted_match_compatibility_aliases, fix_publisher_tmpx_mapping_key_constraints, fix_wholesale_cache_scope_defaults, @@ -7701,6 +9162,22 @@ def main(argv: list[str] | None = None) -> int: point_integer_fields_at_the_schema_integer_type, remove_imports_shadowed_by_a_local_class, remove_unused_pydantic_field_imports, + # After the import-cleanup passes, which would otherwise read the import + # a compatibility annotation adds as unused -- nothing had referenced the + # aliased name at the moment they ran. + # Ahead of the annotations that reference what they emit. + emit_readback_clones, + emit_open_asset_unions, + declare_compatibility_annotations, + # Last of all the class-shaping passes: every pass above edits a class + # by name, and the shared base carries what they put on the flat one. + emit_branch_classes, + # After emit_branch_classes, and after every pass that renames or + # re-bases a class: this one reads a class's BASE CHAIN to find the + # conjunct that grades it, so it has to see the final shapes. Placed + # right after enforce_root_constraints it found nothing -- the classes + # it grades are not in their final form that early. + widen_conjunct_read_literal_defaults, # After every pass that can add, rename or remove a class: the # ``__all__`` it writes has to name the final set. declare_root_schema_module_exports, diff --git a/scripts/post_generation_manifest.json b/scripts/post_generation_manifest.json index 458da9d5e..917c9b91c 100644 --- a/scripts/post_generation_manifest.json +++ b/scripts/post_generation_manifest.json @@ -11,7 +11,7 @@ "fix_typed_additional_properties": true, "fix_deprecated_rootmodel_fields": true, "fix_constr_type_annotations": true, - "fix_postal_union_arm_order": true, + "fix_postal_union_branch_order": true, "fix_postal_country_system_pairing": true, "fix_beta3_adagents_renderer_constraints": true, "unwrap_rootmodel_unions": true, @@ -42,13 +42,13 @@ "fix_canceled_literal_defaults": true, "fix_unchanged_literal_defaults": true, "fix_optional_boolean_literal_defaults": true, - "enforce_root_required_groups": true, + "enforce_root_constraints": true, + "widen_conjunct_read_literal_defaults": true, "fix_reporting_request_selectors": true, "fix_reporting_capability_defaults": true, "fix_protocol_envelope_status_default": true, "fix_trusted_match_runtime_validators": true, "fix_beta3_secure_url_constraints": true, - "fix_beta3_package_request_constraints": true, "restore_trusted_match_compatibility_aliases": true, "fix_publisher_tmpx_mapping_key_constraints": "The generator emits the outer map key as Annotated[str, StringConstraints(...)]; the StringConstraints-only shape this fix repairs no longer appears.", "fix_wholesale_cache_scope_defaults": true, @@ -73,6 +73,10 @@ "point_integer_fields_at_the_schema_integer_type": true, "remove_imports_shadowed_by_a_local_class": true, "remove_unused_pydantic_field_imports": true, + "emit_readback_clones": true, + "emit_open_asset_unions": true, + "declare_compatibility_annotations": true, + "emit_branch_classes": true, "declare_root_schema_module_exports": true, "strip_extra_blank_lines_at_eof": true } diff --git a/scripts/schema_constraints.py b/scripts/schema_constraints.py new file mode 100644 index 000000000..5738c8e9d --- /dev/null +++ b/scripts/schema_constraints.py @@ -0,0 +1,1924 @@ +"""One reader for a schema root's constraint content. + +``datamodel-code-generator`` renders a schema root's ``properties`` and drops the +rest of what the root states: a root ``anyOf``/``oneOf`` over overlay branches, a root +``if``/``then``/``else``, root ``dependencies``, a root ``not``. Each dropped +keyword is a document the generated model accepts and the canonical schema +refuses. + +This module is the only code that reads that content. It produces one +:class:`RootConstraints` record per schema root, and every consumer -- the +validator pass, the response-branch emitter, the union-branch naming rule -- takes the +record and nothing else from the node, so a second divergent reading of the same +node has nothing to call. + +Parsing is key-consuming +------------------------ + +For every node it visits the parser accounts for every key as exactly one of: + +* **Compiled** -- consumed by a grammar production, producing a :data:`Cond`. +* **Non-asserting** -- a member of :data:`NON_ASSERTING_KEYS`, a named frozen + set of keys that carry no validation semantics by JSON Schema's own + definition (annotations, identifiers, the ``$defs`` container, an ``ajv`` + message override). +* **Declared behavioral** -- exactly ``x-adcp-validation``, declared outside the + structural models' promise by ``docs/validation-contract.md`` and carried by + canonical wire validation plus targeted hand-written validators. The parser + records the classification on the record; it is a contract citation, not a + per-schema tolerance entry. +* **Proven restatement** -- a declarative keyword (``type``, ``$ref``, + ``format``, ``additionalProperties``) or a whole property re-declaration that + resolves equal to what the generated class already declares for that + position. Equality is checked, never assumed; a narrowing compiles as a value + predicate instead. + +Anything else raises :class:`UnexpressibleKeywordError`, which aborts generation +naming the file, the JSON pointer and the keyword. There is no list of +known-dropped keywords anywhere in this module: a keyword is compiled, +classified by a named rule, or fatal. That is what makes "the model is weaker +than its schema, silently" unconstructible rather than detected. + +What "what the generated class already declares" means here +----------------------------------------------------------- + +``datamodel-code-generator`` renders a root's merged ``properties`` and nothing +else, so the declaration for a position inside root constraint content is the +schema node at the same position under the root's own ``properties`` -- which is +what the parser descends in parallel with the constraint node. Field *names* are +a separate question, because ``model_fields_set`` holds field names and a schema +states wire names; :class:`DeclaredShape` carries that map, taken from the +generated class's ``model_fields``. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, Union + +#: Resolves the document half of a ``$ref`` to a parsed schema, or ``None``. +ResolveDocument = Callable[[str], Any] + +__all__ = [ + "AllOf", + "AnyOf", + "Branch", + "bundle_document_resolver", + "COMPILED_KEYWORDS", + "DROPPED_KEYWORDS", + "Cond", + "DECLARED_BEHAVIORAL_KEY", + "DeclaredShape", + "DependentRequired", + "EveryItem", + "IfThen", + "KeysSatisfy", + "MatchingKeysSatisfy", + "NON_ASSERTING_KEYS", + "NoOtherProps", + "Not", + "OtherPropsSatisfy", + "OneOf", + "branch_conditions", + "partitions_by_requiredness", + "permits_null", + "PropCount", + "PropSatisfies", + "provably_disjoint", + "read_root_constraints", + "Restated", + "root_carries_constraints", + "root_combinator", + "root_required_names", + "RootConstraints", + "SomeItem", + "Supplied", + "Undeclarable", + "UndisjointOneOfError", + "UnexpressibleKeywordError", + "UnplaceableConstraintError", + "ValueBounds", + "ValueConst", + "ValueEnum", + "ValueItems", + "ValueLen", + "ValuePattern", + "ValueType", + "ValueUnique", +] + + +class UnexpressibleKeywordError(Exception): + """A keyword inside root constraint content that the grammar cannot compile. + + Raising this aborts generation. ``generate_types.py`` generates into a + staging tree and installs only on success, so the checkout stays clean and + the previous generated tree stays in place. + """ + + def __init__(self, schema_file: str, pointer: str, keyword: str) -> None: + self.schema_file = schema_file + self.pointer = pointer + self.keyword = keyword + super().__init__( + f"{schema_file}#{pointer or '/'}: root constraint content carries " + f"{keyword!r}, which the constraint grammar cannot compile. Either add a " + f"grammar production for it in scripts/schema_constraints.py (one Cond " + f"node, one compiler case, one constructed-node test), or classify it by " + f"name if it carries no validation semantics. There is no skip lane." + ) + + +class UnplaceableConstraintError(Exception): + """A root constraint names a field no class for that schema declares. + + That is a generator field-drop surfacing loudly, not something to work + around: the constraint cannot be placed because the field it grades does not + exist on the model. + """ + + def __init__(self, schema_file: str, pointer: str, name: str) -> None: + self.schema_file = schema_file + self.pointer = pointer + self.name = name + super().__init__( + f"{schema_file}#{pointer or '/'}: root constraint content names property " + f"{name!r}, which the generated class for this schema does not declare. " + f"A constraint that cannot be placed is a dropped field, not a keyword to " + f"classify." + ) + + +#: Keys that carry no validation semantics at the position they appear, each +#: named for the reason it is inert. This is the whole set: a key outside it is +#: compiled or fatal. +NON_ASSERTING_KEYS = frozenset( + { + # Annotations: JSON Schema defines these as having no validation effect. + "title", + "description", + "deprecated", + "examples", + "$comment", + "default", + "readOnly", + "writeOnly", + # Identifiers and the subschema container. ``$defs``/``definitions`` + # hold subschemas reachable only through a ``$ref``; at their own + # position they assert nothing. + "$schema", + "$id", + "$defs", + "definitions", + # OpenAPI's serialization hint for a union. It selects which branch a + # document is read as; it adds no constraint of its own. + "discriminator", + # ``ajv``'s message override for a sibling keyword's failure. The + # sibling keyword is what asserts. + "errorMessage", + # JSON Schema makes ``format`` an annotation by default -- an assertion + # only under the format-assertion vocabulary -- and the SDK's models + # carry format semantics in a field's own annotation where pydantic has + # them at all. The formats pydantic lacks (``uri-template``) are a + # pre-existing type-level looseness carried by canonical wire + # validation, outside this defect class and named in the design's + # coverage table rather than silently absorbed here. + "format", + # Per-enum-value documentation, and a maintainer's note. Neither is a + # JSON Schema keyword; both are text alongside the sibling that + # asserts. + "enumDescriptions", + "enumMetadata", + "notes", + # AdCP annotations. + "x-entity", + "x-status", + "x-added-in", + "x-deprecated-in", + "x-removed-in", + # Two boolean markers on a position, both ``true`` wherever the bundle + # writes them: ``x-accessibility`` marks a field as accessibility + # material, ``x-extensible`` marks an enum as open to registry + # additions. Neither says anything about an instance -- the sibling + # ``enum`` is what asserts. + "x-accessibility", + "x-extensible", + # Marks a field whose object value is carried through unmodelled. + # A rendering instruction, not a constraint on the document. + "x-adcp-open-payload", + # Instructions to THIS generator and to a reader of the bundle, not + # statements about an instance: where to hoist a nested model, what to + # inline into model context, which schema URI a node came from, which + # schema is SDK-local, and which result schema a task type overrides. + "x-adcp-hoist", + "x-adcp-model-context-inline", + "x-adcp-schema-uri", + "x-adcp-sdk-local", + "x-task-result-schema-overrides", + # Protocol and registry metadata about the TOOL or the enum, each with + # a sibling that does the asserting. ``x-pattern`` is the shape a + # registry addition to an ``x-extensible`` enum should take -- the + # sibling ``enum`` is what the canonical validator applies, so treating + # it as an assertion would make the model STRICTER than the bundled + # schema, which the differential parity suite grades. + "x-pattern", + "x-enum-descriptions", + "x-deprecated-enum-values", + "x-deprecated-enum-values-doc", + "x-entity-definitions", + "x-mutates-state", + "x-governed-commitment", + "x-idempotency-key-required", + "x-operation-family", + "x-tool-summary", + "x-superseded-by", + "x-legacy-fallback", + "x-compatibility-projection-only", + # Units for a sibling size bound, and how to canonicalize a value for + # signing. Neither bounds what the document may contain. + "x-adcp-file-size-units", + "x-canonicalization", + # A note stating, in prose, that the canonical format schemas do NOT + # mark parameter fields required. It declares a non-enforcement. + "x-canonical-policy-required-params-not-enforced", + } +) + +#: The one key whose rules are declared outside the structural models' promise. +DECLARED_BEHAVIORAL_KEY = "x-adcp-validation" + +#: Every keyword the grammar has a production for. Each member is graded twice +#: by ``tests/test_schema_constraints.py``: a constructed node carrying it must +#: compile, and the fixture map must cover exactly this set, so a production +#: added without a test and a claim made without a production both fail. +COMPILED_KEYWORDS = frozenset( + { + "$ref", + "additionalProperties", + "allOf", + "anyOf", + "const", + "contains", + "dependencies", + "dependentRequired", + "dependentSchemas", + "else", + "enum", + "exclusiveMaximum", + "exclusiveMinimum", + "if", + "items", + "maxItems", + "maxLength", + "maxProperties", + "maximum", + "minItems", + "minLength", + "minProperties", + "minimum", + "not", + "oneOf", + "pattern", + "patternProperties", + "properties", + "propertyNames", + "required", + "then", + "type", + "uniqueItems", + } +) + +#: Whole-object and whole-array keywords ``datamodel-code-generator`` DROPS: it +#: has no field to hang them on, so nothing in the generated class carries them. +#: +#: They are the reason this set has to be named at all. Everything else the +#: grammar compiles is either a logic key -- which the generator also drops, and +#: which :data:`LOGIC_KEYS` already names -- or a declarative key the generator +#: renders as a field constraint, where the restatement rule correctly reports it +#: as already enforced. These six are in neither category: declarative in form, +#: dropped in fact. Treating them as declarative is what shut all three doors +#: below, each independently sufficient to lose the constraint: +#: +#: * :func:`_states_logic` did not admit a root carrying one, so its content was +#: never examined (salesagent-09ghh, and the same shape as the ``allOf`` gate +#: closed by a2989c205); +#: * :meth:`_Parser.read` read the root's combinator, ``if``, ``dependencies``, +#: ``dependentRequired``, ``not`` and ``allOf`` members -- so a top-level +#: ``minProperties`` sat in no position it looked at, and widening the gate +#: alone produced ten admitted roots and ten EMPTY validators; +#: * :func:`_declaration_of` kept them as "what the generator renders", so the +#: restatement rule at :meth:`_Parser._keyword` reported them already enforced +#: -- which would have defeated a fix to the first two for every ``allOf`` +#: member case. +#: +#: ``uniqueItems`` is in the set because the gate must admit its roots, not +#: because a root conjunct can carry it: an array root renders as a ``RootModel`` +#: with no object field, so ``flat_root_shape`` returns ``None`` and no root +#: validator is emitted. Putting it on the element type is salesagent-pizni. +DROPPED_KEYWORDS = frozenset( + { + "contains", + "maxProperties", + "minProperties", + "patternProperties", + "propertyNames", + "uniqueItems", + } +) + +#: Keys whose presence makes a node a constraint rather than a declaration. The +#: generator renders a node's declarative keys and drops these. +LOGIC_KEYS = frozenset( + { + "anyOf", + "oneOf", + "allOf", + "not", + "if", + "then", + "else", + "dependencies", + "dependentRequired", + "dependentSchemas", + } +) + +#: The key :func:`_declaration_of` adds to carry every ``$ref`` a node declares, +#: its own and its ``allOf`` members'. A class inherits from every member, so a +#: restatement of any member's reference is already declared. Not a JSON Schema +#: keyword, so it collides with nothing a schema can write. +_DECLARED_REFS = "$refs" + + +# --------------------------------------------------------------------------- # +# The grammar +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class Supplied: + """``required``: the field is present on the document that is sent. + + Presence is ``model_fields_set`` membership, never an ``is None`` test: a + default the caller never sent is not supplied even when it is not ``None``. + + ``nullable`` says whether the SCHEMA permits ``null`` at this position, and + it is what membership alone cannot carry. Where null is permitted, an + explicit null is the caller's value -- on a mutation input, the command to + clear -- and the client keeps the key on the wire, so membership is the + whole question. Where it is not, ``exclude_none`` drops the key and + ``preserve_explicit_nulls`` does not restore it, so the document that is + SENT does not carry it and a check reading it as present refuses a request + no seller receives. Measured on ``control-media-buy-request``: ``canceled`` + is ``Literal[True]``, and ``canceled=None`` with a real mutation beside it + is dropped on the way out. + + Defaults to ``False`` because that is what most positions declare -- a + property with a plain ``type`` forbids null -- so a constructed node reads + as the common case and only a nullable position spells it out. The reader + never relies on the default: it derives the value from the declaration it + is already walking. + """ + + name: str + nullable: bool = False + + +@dataclass(frozen=True) +class Not: + operand: Cond + + +@dataclass(frozen=True) +class AllOf: + operands: tuple[Cond, ...] + + +@dataclass(frozen=True) +class AnyOf: + operands: tuple[Cond, ...] + + +@dataclass(frozen=True) +class OneOf: + operands: tuple[Cond, ...] + + +@dataclass(frozen=True) +class IfThen: + """``if``/``then``/``else`` as an implication over the condition.""" + + condition: Cond + consequent: Cond | None + alternative: Cond | None + + +@dataclass(frozen=True) +class PropSatisfies: + """Descend into a supplied value. + + Vacuously true when the property was not supplied or is an explicit null: + JSON Schema's ``properties`` grades present object members only. + """ + + name: str + operand: Cond + + +@dataclass(frozen=True) +class ValueConst: + value: Any + + +@dataclass(frozen=True) +class ValueEnum: + values: tuple[Any, ...] + + +@dataclass(frozen=True) +class ValueType: + types: tuple[str, ...] + + +@dataclass(frozen=True) +class ValuePattern: + pattern: str + + +@dataclass(frozen=True) +class ValueLen: + minimum: int | None = None + maximum: int | None = None + + +@dataclass(frozen=True) +class ValueBounds: + ge: float | None = None + le: float | None = None + gt: float | None = None + lt: float | None = None + + +@dataclass(frozen=True) +class ValueItems: + minimum: int | None = None + maximum: int | None = None + + +@dataclass(frozen=True) +class ValueUnique: + pass + + +@dataclass(frozen=True) +class EveryItem: + """``items``: every element satisfies the operand.""" + + operand: Cond + + +@dataclass(frozen=True) +class SomeItem: + """``contains``: at least one element satisfies the operand.""" + + operand: Cond + + +@dataclass(frozen=True) +class KeysSatisfy: + """``propertyNames``: every key satisfies the operand.""" + + operand: Cond + + +@dataclass(frozen=True) +class MatchingKeysSatisfy: + """``patternProperties``: every value under a matching key satisfies it.""" + + pattern: str + operand: Cond + + +@dataclass(frozen=True) +class PropCount: + minimum: int | None = None + maximum: int | None = None + + +@dataclass(frozen=True) +class DependentRequired: + """``dependencies``/``dependentRequired``: presence implies presence.""" + + name: str + names: tuple[str, ...] + + +@dataclass(frozen=True) +class NoOtherProps: + """``additionalProperties: false``: only the named members may be supplied. + + The sibling ``properties`` names what may be present, and a generated class + normally declares more fields than one branch's ``properties`` names, so this + is a bound on what the caller supplied rather than on what the class can + hold. + """ + + names: tuple[str, ...] + + +@dataclass(frozen=True) +class OtherPropsSatisfy: + """``additionalProperties`` as a subschema: unnamed members satisfy it. + + With no names, every member does -- which is how the bundle spells a map + type, ``{"additionalProperties": {"enum": [...]}}``. + """ + + names: tuple[str, ...] + operand: Cond + + +@dataclass(frozen=True) +class Undeclarable: + """``required`` naming a property the schema itself never declares. + + An AdCP request root that forbids ``creatives`` names a key the document + may not carry at all. A generated model has no field for it and cannot be + given one, so the predicate is false for every instance -- which is what + makes the branch forbidding it satisfiable and the branch requiring it dead. + """ + + name: str + + +@dataclass(frozen=True) +class Restated: + """A keyword proven identical to what the generated class declares. + + True of every instance of that class, which makes substituting it sound in + a negated position as much as a positive one. Carries the keyword so a + reader of the record can see what was accounted for rather than inferring + it from an absence. + """ + + keyword: str + + +Cond = Union[ + "Supplied", + "NoOtherProps", + "OtherPropsSatisfy", + "Undeclarable", + "Not", + "AllOf", + "AnyOf", + "OneOf", + "IfThen", + "PropSatisfies", + "ValueConst", + "ValueEnum", + "ValueType", + "ValuePattern", + "ValueLen", + "ValueBounds", + "ValueItems", + "ValueUnique", + "EveryItem", + "SomeItem", + "KeysSatisfy", + "MatchingKeysSatisfy", + "PropCount", + "DependentRequired", + "Restated", +] + + +# --------------------------------------------------------------------------- # +# The record +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class DeclaredShape: + """The wire-to-field map the generated class declares for one object. + + ``model_fields_set`` holds field names and a schema states wire names; a + field aliased ``schema`` appears as ``schema_``. Every schema-named property + in a compiled predicate passes through this map before it reaches the + record, so no emitter has to re-derive it. + + ``nested`` carries the same map for a field whose value is itself an object + the parser descends into. A field with no entry is :data:`OPAQUE`: the + parser could not resolve the nested class, so it carries wire names through + unchanged. ``OPAQUE`` also answers "declares every name", which is what + keeps an unresolved nesting from manufacturing a field-drop refusal. + + ``closed`` says no key outside ``fields`` can reach the object at all -- + ``additionalProperties: false``, which the generator renders as + ``extra='forbid'``. It decides what a constraint naming an undeclared key + means: under a closed shape the key can never be present, so the predicate + is statically false (:class:`Undeclarable`); under an open one pydantic + carries the key as an extra, puts it in ``model_fields_set`` and resolves it + through ``getattr``, so the predicate reads it off the document under its + wire name. Both were measured: ``control-media-buy-request`` is closed and + forbids ``creatives``; ``get-reporting-status-response`` is open and its + nested branch names ``failure_kind``, which no field holds. + """ + + fields: Mapping[str, str] = field(default_factory=dict) + nested: Mapping[str, DeclaredShape] = field(default_factory=dict) + opaque: bool = False + closed: bool = False + + def attribute(self, wire_name: str) -> str | None: + """The attribute for a wire name, or ``None`` when undeclared.""" + if self.opaque: + return wire_name + return self.fields.get(wire_name) + + def descend(self, wire_name: str) -> DeclaredShape: + return self.nested.get(wire_name, OPAQUE) + + @classmethod + def from_schema(cls, schema: Any) -> DeclaredShape: + """The map a schema's own declarations imply, with identity names. + + Every property any part of a root declares, because the generator emits + one class per declaring branch and a root-level constraint may name a field + a sibling branch declares. Used where no generated class is available -- + the bundle-closure test, and a nested object the parser cannot resolve + to a class. It answers the keyword-accounting question faithfully, which + is what the closure rule grades, and makes no claim about aliases, which + only ``model_fields`` can answer. + """ + properties = _declared_properties(schema) + if not properties: + return OPAQUE + return cls( + fields={name: name for name in properties}, + nested={name: cls.from_schema(sub) for name, sub in properties.items()}, + closed=isinstance(schema, dict) and schema.get("additionalProperties") is False, + ) + + +#: A shape the parser could not resolve: identity names, declares everything. +OPAQUE = DeclaredShape(opaque=True) + + +@dataclass(frozen=True) +class Branch: + """One branch of a schema root's ``anyOf``/``oneOf``.""" + + pointer: str + """``"anyOf/0"`` -- provenance, never identity.""" + + title: str | None + """Identity source 1.""" + + discriminator: tuple[str, str] | None + """Identity source 2: ``(property, const)``.""" + + required: tuple[str, ...] + """Identity source 3, and a constraint. Attribute names, not wire names.""" + + declares_fields: bool + """``properties`` introducing new fields: a class-creating branch.""" + + condition: Cond + """The WHOLE branch as one constraint tree.""" + + declares_behavioral_rules: bool = False + """The branch carries ``x-adcp-validation``, so its cardinality is not derivable.""" + + +@dataclass(frozen=True) +class RootConstraints: + """Everything a schema root states beyond its merged ``properties``.""" + + combinator: Literal["anyOf", "oneOf"] | None + branches: tuple[Branch, ...] + conjuncts: tuple[Cond, ...] + """Root ``if``/``then``/``else``, ``dependencies``, ``dependentRequired``, + root ``not``, and the constraint branches of a root ``allOf``.""" + + root_required: frozenset[str] = frozenset() + """What the SCHEMA ROOT requires unconditionally. Attribute names. + + The branch emitter needs this because ``datamodel-code-generator`` merges the + branches' properties onto one flat class and carries their requiredness with + them, so the merged class requires exactly the keys its branches SELECT BY + while the root requires none of them (salesagent-8qela.80). Reading this set + is what lets the shared base keep the root's requiredness and leave each + branch its own. + + It is NOT :attr:`Branch.required` intersected: a branch may omit a key the + root requires, so an intersection under-requires. It is also not + ``schema["required"]`` alone -- see :func:`root_required_names`. + """ + + declares_behavioral_rules: bool = False + + def __bool__(self) -> bool: + return bool(self.branches or self.conjuncts) + + +# --------------------------------------------------------------------------- # +# The parser +# --------------------------------------------------------------------------- # + + +def root_combinator(schema: Any) -> Literal["anyOf", "oneOf"] | None: + """Which combinator the root declares over its branches, if either. + + One owner, because two emitters read it and a refusal NAMES it: the branch + emitter puts it on the split root's wrapping validator, and the response + emitter puts it on the compatibility base's. A keyword that disagreed + between them would be a refusal citing a keyword the schema does not use. + + ``anyOf`` is tested first and the first match wins, which is the precedence + the reader has always applied: a root declaring both states the stricter + rule in its ``oneOf``, and the branches are read from one node. + """ + if not isinstance(schema, dict): + return None + candidates: tuple[Literal["anyOf", "oneOf"], ...] = ("anyOf", "oneOf") + for candidate in candidates: + node = schema.get(candidate) + if isinstance(node, list) and node: + return candidate + return None + + +def root_required_names( + schema: Any, + resolve_document: ResolveDocument | None = None, + _seen: frozenset[str] = frozenset(), +) -> frozenset[str]: + """The WIRE names a schema root requires unconditionally. + + One owner, because two callers compare the same two things and a + disagreement between them is unreadable: the branch emitter decides which + of the merged class's required fields to relax, and the guard that grades + the result decides which are over-required. A second derivation of this + would be a second answer. + + ``schema["required"]`` IS NOT THAT SET. A root ``allOf`` is inheritance for + the declarative keys the generator merges, and a ``required`` inside a + member -- or inside the schema a member ``$ref``s -- binds exactly as the + root's own key does. ``account/list-account-changes-response`` declares no + ``required`` of its own and ``allOf``s ``core/protocol-envelope``, which + requires ``status``; asked against jsonschema, a document omitting + ``status`` fails with ``required``. So the generated class requiring it is + right, and a derivation reading only the root's own key calls that a defect + and relaxes a field the pin requires. + + ``allOf`` and nothing else: a ``oneOf``/``anyOf`` branch's ``required`` is + what the branch SELECTS BY, and ``if``/``then`` is conditional. Over the 18 + required fields on the 13 split-root bases at the 3.2.1 pin, this derivation + agrees with jsonschema on all 18 and the root's own key alone on 17. + """ + if not isinstance(schema, dict): + return frozenset() + names = {name for name in schema.get("required") or () if isinstance(name, str)} + for member in schema.get("allOf") or (): + if not isinstance(member, dict): + continue + ref = member.get("$ref") + if isinstance(ref, str) and ref not in _seen: + document, fragment = (ref.split("#", 1) + [""])[:2] + target = resolve_document(document) if document and resolve_document else schema + if target is not None: + names |= root_required_names( + _follow_pointer(target, fragment), resolve_document, _seen | {ref} + ) + names |= root_required_names( + {key: value for key, value in member.items() if key != "$ref"}, + resolve_document, + _seen, + ) + return frozenset(names) + + +def root_carries_constraints(schema: Any) -> bool: + """Whether a schema root states anything beyond its merged ``properties``. + + This predicate is the reader's ADMISSION gate, so what it misses is content + nothing examines -- not content examined and skipped. It used to test only + the root's own keys plus a root ``anyOf``/``oneOf``, and this bundle puts + conditional rules inside ``allOf`` MEMBERS: 112 roots carried + ``allOf: [{if, then}, ...]`` and were reported as carrying nothing. + ``core/reporting-obligation`` has ten such members, one of which requires + ``pending_adjustment_count``, and the model accepted documents the schema + refused for exactly that reason. + + So an ``allOf`` member is examined too. A root ``allOf`` is inheritance for + the DECLARATIVE keys the generator merges, and a carrier of logic for the + keys it drops -- the same list either way, read for two purposes. + """ + if not isinstance(schema, dict): + return False + if _states_logic(schema): + return True + members = schema.get("allOf") + return isinstance(members, list) and any(_states_logic(member) for member in members) + + +def _states_logic(node: Any) -> bool: + """Whether a node carries a keyword the generator drops.""" + if not isinstance(node, dict): + return False + if any( + key in node + for key in ("if", "dependencies", "dependentRequired", "dependentSchemas", "not") + ): + return True + # A whole-object keyword is dropped exactly as a logic key is, so a root + # carrying one states something no field of the class expresses. + if any(key in node for key in DROPPED_KEYWORDS): + return True + return any(isinstance(node.get(key), list) and node[key] for key in ("anyOf", "oneOf")) + + +#: Positions whose subschema DECLARES the same object as its parent, so a +#: property named there is a property of the parent. The conditional and +#: negated positions (``if``/``then``/``else``/``not``) describe the same object +#: too, but they declare nothing -- see :func:`_declared_properties`. +_SAME_OBJECT_LISTS = ("allOf", "anyOf", "oneOf") + + +def _declaration_of(node: Any) -> dict[str, Any]: + """The part of a node the generator renders: its declarative keys. + + A node's logic keys are exactly what the generator drops, so they are never + part of the declaration a restatement is proven against -- with one + exception the generator does render: a root ``allOf`` is inheritance, and + its members' declarations are merged into the class. So the declaration a + restatement is proven against includes them. + """ + if not isinstance(node, dict): + return {} + # ``DROPPED_KEYWORDS`` leave with the logic keys: the generator renders + # neither, so neither is part of the declaration a restatement is proven + # against. Keeping them here made ``minProperties`` inside an ``allOf`` + # member compile to ``Restated('declaration')`` against a class that + # declares no such thing. + declaration = { + key: value + for key, value in node.items() + if key not in LOGIC_KEYS and key not in DROPPED_KEYWORDS + } + refs = [declaration["$ref"]] if isinstance(declaration.get("$ref"), str) else [] + members = node.get("allOf") + if isinstance(members, list): + properties = dict(declaration.get("properties") or {}) + for member in members: + inherited = _declaration_of(member) + properties.update(inherited.get("properties") or {}) + refs.extend(inherited.get(_DECLARED_REFS, ())) + for key, value in inherited.items(): + if key not in ("properties", _DECLARED_REFS): + declaration.setdefault(key, value) + if properties: + declaration["properties"] = properties + if refs: + declaration[_DECLARED_REFS] = tuple(refs) + return declaration + + +def _declared_properties(node: Any) -> dict[str, Any]: + """Every property name any part of one object's schema declares. + + A root constraint may name a field a sibling branch declares -- an branch that + forbids ``errors`` is the branch whose sibling declares it -- so the set of + names a root's constraint content may reference is the union over the + positions that DECLARE the object: its own ``properties``, its ``allOf`` + members' (inheritance, which the generator merges) and its branches'. + + A negated or conditional position declares nothing. ``not``, ``if``, + ``then``, ``else`` and the ``dependencies`` maps are constraint content -- + the generator renders no field from any of them -- so a property name + reachable only through one of those is a key the document may carry and no + class declares, which is :class:`Undeclarable` and decidable, not a dropped + field. ``get-creative-features-terminal-success.json`` is the measured case: + its root ``not`` forbids ``status: submitted``, and reading that as a + declaration turned a compilable absence ban into a field-drop refusal. + """ + if not isinstance(node, dict): + return {} + collected: dict[str, Any] = {} + properties = node.get("properties") + if isinstance(properties, dict): + collected.update(properties) + for key in _SAME_OBJECT_LISTS: + members = node.get(key) + if isinstance(members, list): + for member in members: + collected.update(_declared_properties(member)) + return collected + + +class _Parser: + """Compiles one schema root. One instance per root, so errors carry the file.""" + + def __init__( + self, + schema_file: str, + declared: DeclaredShape, + resolve_document: ResolveDocument | None, + *, + branches_render_as_classes: bool, + ) -> None: + self._file = schema_file + self._root_shape = declared + self._resolve_document = resolve_document + self._branches_render_as_classes = branches_render_as_classes + self._schema_names: frozenset[str] = frozenset() + self._root_names: frozenset[str] = frozenset() + self._document: Any = {} + self._resolving: frozenset[str] = frozenset() + self.behavioral = False + + # -- entry point -------------------------------------------------------- # + + def read(self, schema: dict[str, Any]) -> RootConstraints: + combinator: Literal["anyOf", "oneOf"] | None = None + branches: tuple[Branch, ...] = () + root_declaration = _declaration_of(schema) + self._root_names = frozenset(root_declaration.get("properties") or {}) + self._schema_names = frozenset(_declared_properties(schema)) + self._document = schema + + combinator = root_combinator(schema) + if combinator is not None: + branches = tuple( + self._read_branch(branch, f"{combinator}/{index}", root_declaration) + for index, branch in enumerate(schema[combinator]) + ) + + conjuncts: list[Cond] = [] + if "if" in schema: + conjuncts.append(self._if_then(schema, "", root_declaration, self._root_shape)) + # ``dependentSchemas`` beside its two siblings: all three are dropped by + # codegen, all three compile through ``_dependencies``, and the gate and + # this loop both named only two. No schema in the pinned bundle uses it, + # so this closes a latent hole rather than changing a verdict -- which is + # the same defect class as the other four doors and is why + # tests/test_dropped_keyword_doors.py grades the partition rather than + # the instances. + for keyword in ("dependencies", "dependentRequired", "dependentSchemas"): + if keyword in schema: + conjuncts.extend(self._dependencies(schema[keyword], keyword, "", self._root_shape)) + if "not" in schema: + conjuncts.append( + Not(self._node(schema["not"], "not", root_declaration, self._root_shape)) + ) + # A root ``allOf`` is two things at once, and both are read. Its + # ``$ref`` members are inheritance, which the generator renders and + # ``_declaration_of`` therefore counts as declared, so they compile to + # ``Restated`` and drop out here. Its inline members are conjuncts + # beside whatever else the root states -- including beside a + # combinator: ``create-media-buy-request``'s ``allOf`` carries the + # opportunity-implies-proposal rule and the bidding rule next to its + # three-branch ``anyOf``, and reading them only in the absence of a + # combinator dropped both. + if isinstance(schema.get("allOf"), list): + for index, member in enumerate(schema["allOf"]): + compiled = self._node(member, f"allOf/{index}", root_declaration, self._root_shape) + if not isinstance(compiled, Restated): + conjuncts.append(compiled) + # The root's OWN whole-object keywords. They sit in none of the positions + # above -- not in the combinator, not in ``if``, not in an ``allOf`` + # member -- so without this the gate admits the root and the reader hands + # the grammar nothing. Compiled as a node of just those keys, which is + # what they constrain: the object the root declares. + dropped = {key: schema[key] for key in sorted(DROPPED_KEYWORDS) if key in schema} + if dropped: + compiled = self._node(dropped, "", root_declaration, self._root_shape) + if not isinstance(compiled, Restated): + conjuncts.append(compiled) + + # The root's own requiredness, as FIELD names. A required wire name no + # field holds is not a field an emitter can relax, so it drops out here + # rather than refusing: a dropped field is its own defect and aborting + # generation for it would report this one in its place. + root_required = frozenset( + attribute + for name in root_required_names(schema, self._resolve_document) + if (attribute := self._root_shape.attribute(name)) is not None + ) + + return RootConstraints( + combinator=combinator, + branches=branches, + conjuncts=tuple(conjuncts), + root_required=root_required, + declares_behavioral_rules=self.behavioral, + ) + + # -- branches --------------------------------------------------------------- # + + def _read_branch(self, branch: Any, pointer: str, root_declaration: dict[str, Any]) -> Branch: + if not isinstance(branch, dict): + raise UnexpressibleKeywordError(self._file, pointer, type(branch).__name__) + + root_properties = root_declaration.get("properties") + root_names = set(root_properties) if isinstance(root_properties, dict) else set() + branch_declaration = _declaration_of(branch) + branch_properties = branch_declaration.get("properties") + branch_names = set(branch_properties) if isinstance(branch_properties, dict) else set() + declares_fields = bool(branch_names - root_names) + + # An branch the generator renders as ITS OWN TYPE states its own + # declaration: a bare ``$ref`` becomes the referenced type and an branch of + # a different scalar type becomes that type, so a keyword restating + # either is already enforced. An OVERLAY branch on a FLAT root states + # constraint content instead, and is proven against the ROOT's + # declaration -- one class holds the merged properties, and what the branch + # says about a position is enforced only where the root says the same. + # + # Whether an branch that introduces a property becomes a class is the one + # part that is NOT inferable from the schema, which is why the caller + # supplies it. Inferring it from ``declares_fields`` is what + # ``list-account-changes-response`` measured wrong: one flat class over + # both branches, each branch introducing ``status``, so each branch's ``required`` + # and ``properties`` were proven restatements of themselves and the + # model was left enforcing nothing at all. + renders_own_shape = ( + (declares_fields and self._branches_render_as_classes) + or "$ref" in branch_declaration + or branch_declaration.get("type", root_declaration.get("type")) + != root_declaration.get("type") + ) + declaration = branch_declaration if renders_own_shape else root_declaration + + before = self.behavioral + self.behavioral = False + condition = self._node(branch, pointer, declaration, self._root_shape) + branch_behavioral = self.behavioral + self.behavioral = before or branch_behavioral + + # Identity reads the names the branch requires OF THE CLASS; a name the + # document may not carry identifies nothing. + required = tuple( + attribute + for attribute in ( + self._attribute(name, self._root_shape, pointer) + for name in branch.get("required", ()) + if isinstance(name, str) + ) + if attribute is not None + ) + return Branch( + pointer=pointer, + title=branch.get("title") if isinstance(branch.get("title"), str) else None, + discriminator=self._discriminator(branch), + required=required, + declares_fields=declares_fields, + condition=condition, + declares_behavioral_rules=branch_behavioral, + ) + + @staticmethod + def _discriminator(branch: dict[str, Any]) -> tuple[str, str] | None: + """The ``(property, const)`` pair an branch pins exactly one property to.""" + properties = branch.get("properties") + if not isinstance(properties, dict): + return None + pinned = [ + (name, sub["const"]) + for name, sub in properties.items() + if isinstance(sub, dict) and isinstance(sub.get("const"), str) + ] + if len(pinned) != 1: + return None + return pinned[0] + + # -- the key-consuming walk --------------------------------------------- # + + def _node( + self, + node: Any, + pointer: str, + declaration: Any, + shape: DeclaredShape, + ) -> Cond: + """Compile one schema node, accounting for every key it carries.""" + if node is True or node == {}: + return Restated("true") + if node is False: + # ``false`` admits nothing. Spelled as a logic node so a caller + # never has to special-case a bare boolean. + return Not(Restated("true")) + if not isinstance(node, dict): + raise UnexpressibleKeywordError(self._file, pointer, type(node).__name__) + + declaration = declaration if isinstance(declaration, dict) else {} + operands: list[Cond] = [] + + for keyword in sorted(node): + value = node[keyword] + if keyword in NON_ASSERTING_KEYS: + continue + if keyword == DECLARED_BEHAVIORAL_KEY: + self.behavioral = True + continue + compiled = self._keyword(keyword, value, node, pointer, declaration, shape) + if compiled is None: + raise UnexpressibleKeywordError(self._file, pointer, keyword) + if isinstance(compiled, Restated): + continue + operands.append(compiled) + + if not operands: + return Restated("declaration") + if len(operands) == 1: + return operands[0] + return AllOf(tuple(operands)) + + def _keyword( # noqa: C901, PLR0911, PLR0912 + self, + keyword: str, + value: Any, + node: dict[str, Any], + pointer: str, + declaration: dict[str, Any], + shape: DeclaredShape, + ) -> Cond | None: + """One grammar production, or ``None`` when the grammar has none.""" + here = f"{pointer}/{keyword}" if pointer else keyword + + # The restatement rule, in one place and for every keyword: a keyword + # whose value is exactly what the generated class declares at this + # position is already enforced by the class, so it is true of every + # instance. A keyword that NARROWS the declaration is not equal to it + # and falls through to a production below -- equality is checked, never + # assumed. + if keyword in declaration and _equal(value, declaration[keyword]): + return Restated(keyword) + + if keyword == "required": + return _conjoin( + self._supplied(name, shape, pointer, declaration) + for name in value + if isinstance(name, str) + ) + + if keyword == "properties": + parts: list[Cond] = [] + declared_properties = declaration.get("properties") + declared_properties = ( + declared_properties if isinstance(declared_properties, dict) else {} + ) + for name in sorted(value): + sub = value[name] + declared_sub = declared_properties.get(name) + if declared_sub is not None and _equal(sub, declared_sub): + continue + attribute = self._attribute(name, shape, pointer) + if attribute is None: + # ``properties`` grades present members only, and a key the + # document may not carry is never present. + continue + inner = self._node(sub, f"{here}/{name}", declared_sub, shape.descend(name)) + if isinstance(inner, Restated): + continue + parts.append(PropSatisfies(attribute, inner)) + return _conjoin(parts) + + if keyword == "patternProperties": + parts = [] + for pattern in sorted(value): + inner = self._node(value[pattern], f"{here}/{pattern}", None, OPAQUE) + if isinstance(inner, Restated): + continue + parts.append(MatchingKeysSatisfy(pattern, inner)) + return _conjoin(parts) + + if keyword == "additionalProperties": + if value is True: + return Restated(keyword) + if value is False: + # Only the sibling ``properties`` members may be supplied. A + # generated class normally declares more fields than one branch + # names, so this bounds what the caller supplied. + if "patternProperties" in node: + # The admissible key set is then not enumerable. + return None + return NoOtherProps(self._sibling_names(node, shape, pointer)) + if "patternProperties" in node: + return None + inner = self._node(value, here, None, OPAQUE) + if isinstance(inner, Restated): + return Restated(keyword) + return OtherPropsSatisfy(self._sibling_names(node, shape, pointer), inner) + + if keyword == "not": + # ``not``, ``if``, ``then`` and ``else`` grade the SAME object as + # their parent, so a restatement inside one is proven against the + # parent's declaration. A predicate the declaration already + # enforces is true of every instance, which is exactly as sound + # under a negation (where it makes the branch unsatisfiable) as it is + # on the positive path. + return Not(self._node(value, here, declaration, shape)) + if keyword == "allOf": + return _conjoin( + self._node(member, f"{here}/{index}", declaration, shape) + for index, member in enumerate(value) + ) + if keyword == "anyOf": + return AnyOf( + tuple( + self._node(member, f"{here}/{index}", declaration, shape) + for index, member in enumerate(value) + ) + ) + if keyword == "oneOf": + return OneOf( + tuple( + self._node(member, f"{here}/{index}", declaration, shape) + for index, member in enumerate(value) + ) + ) + if keyword == "if": + return self._if_then(node, pointer, declaration, shape) + if keyword in ("then", "else"): + # Consumed by the sibling ``if``; without one, JSON Schema's own + # reading is that the keyword asserts nothing. + return Restated(keyword) + + if keyword in ("dependencies", "dependentRequired"): + return _conjoin(self._dependencies(value, keyword, pointer, shape)) + if keyword == "dependentSchemas": + return _conjoin(self._dependencies(value, keyword, pointer, shape)) + + if keyword == "const": + return ValueConst(value) + if keyword == "enum": + return ValueEnum(tuple(value)) + if keyword == "type": + return ValueType((value,) if isinstance(value, str) else tuple(value)) + if keyword == "pattern": + return ValuePattern(value) + if keyword == "$ref": + if value in declaration.get(_DECLARED_REFS, ()): + return Restated(keyword) + # A reference the declaration does not name is a narrowing: the + # position has to satisfy the referenced schema as well. Resolve it + # and compile what it says. An unresolvable reference refuses -- + # assuming an unproven equality is the one thing this module will + # not do. + return self._reference(value, here, declaration, shape) + + if keyword == "minLength": + return ValueLen(minimum=value) + if keyword == "maxLength": + return ValueLen(maximum=value) + if keyword == "minimum": + return ValueBounds(ge=value) + if keyword == "maximum": + return ValueBounds(le=value) + if keyword == "exclusiveMinimum": + return ValueBounds(gt=value) + if keyword == "exclusiveMaximum": + return ValueBounds(lt=value) + if keyword == "minItems": + return ValueItems(minimum=value) + if keyword == "maxItems": + return ValueItems(maximum=value) + if keyword == "uniqueItems": + return ValueUnique() if value else Restated(keyword) + if keyword == "minProperties": + return PropCount(minimum=value) + if keyword == "maxProperties": + return PropCount(maximum=value) + + if keyword == "items": + declared_items = declaration.get("items") + if declared_items is not None and _equal(value, declared_items): + return Restated(keyword) + return EveryItem(self._node(value, here, declared_items, OPAQUE)) + if keyword == "contains": + return SomeItem(self._node(value, here, None, OPAQUE)) + if keyword == "propertyNames": + return KeysSatisfy(self._node(value, here, None, OPAQUE)) + + return None + + def _sibling_names( + self, node: dict[str, Any], shape: DeclaredShape, pointer: str + ) -> tuple[str, ...]: + """The members a node's own ``properties`` names, as field names.""" + declared = node.get("properties") + if not isinstance(declared, dict): + return () + resolved = (self._attribute(name, shape, pointer) for name in sorted(declared)) + return tuple(name for name in resolved if name is not None) + + # -- references --------------------------------------------------------- # + + def _reference( + self, ref: str, pointer: str, declaration: dict[str, Any], shape: DeclaredShape + ) -> Cond | None: + """Compile the schema a ``$ref`` names, or ``None`` when unresolvable. + + The referenced schema lands at the position the reference occupies, so + it is graded against the same declaration -- the target is constraint + content, not a declaration of its own. + + Descending into another document makes that document the one a nested + local pointer resolves against. ``change-term-constraints.json`` spells + a money amount ``{"$ref": "#/definitions/money"}``, and resolving it + against whichever root the walk started from finds nothing. + """ + if ref in self._resolving: + # A recursive reference states nothing new at this depth. + return Restated("$ref") + document, target = self._resolve_reference(ref) + if target is None: + return None + previous = self._document + self._resolving = self._resolving | {ref} + self._document = document + try: + return self._node(target, pointer, declaration, shape) + finally: + self._resolving = self._resolving - {ref} + self._document = previous + + def _resolve_reference(self, ref: str) -> tuple[Any, Any | None]: + """The document the reference lands in, and the node it names.""" + document, fragment = (ref.split("#", 1) + [""])[:2] if "#" in ref else (ref, "") + if document: + resolved = self._resolve_document(document) if self._resolve_document else None + if resolved is None: + return self._document, None + else: + resolved = self._document + return resolved, _follow_pointer(resolved, fragment) + + # -- productions needing two keys --------------------------------------- # + + def _if_then( + self, node: dict[str, Any], pointer: str, declaration: Any, shape: DeclaredShape + ) -> Cond: + base = f"{pointer}/" if pointer else "" + condition = self._node(node["if"], f"{base}if", declaration, shape) + consequent = ( + self._node(node["then"], f"{base}then", declaration, shape) if "then" in node else None + ) + alternative = ( + self._node(node["else"], f"{base}else", declaration, shape) if "else" in node else None + ) + return IfThen(condition, consequent, alternative) + + def _dependencies( + self, value: Any, keyword: str, pointer: str, shape: DeclaredShape + ) -> list[Cond]: + here = f"{pointer}/{keyword}" if pointer else keyword + parts: list[Cond] = [] + for name in sorted(value): + dependents = value[name] + trigger = self._attribute(name, shape, pointer) + if trigger is None: + # A key the document may not carry never triggers. + continue + if isinstance(dependents, list): + required = [ + self._supplied(dependent, shape, pointer) + for dependent in dependents + if isinstance(dependent, str) + ] + if all(isinstance(part, Supplied) for part in required): + parts.append( + DependentRequired( + trigger, + tuple(part.name for part in required), # type: ignore[union-attr] + ) + ) + else: + parts.append(IfThen(Supplied(trigger), _conjoin(required), None)) + continue + # The schema form of ``dependencies``: presence implies a subschema. + parts.append( + IfThen( + Supplied(trigger), + self._node(dependents, f"{here}/{name}", None, shape), + None, + ) + ) + return parts + + # -- names -------------------------------------------------------------- # + + def _attribute(self, wire_name: str, shape: DeclaredShape, pointer: str) -> str | None: + """The field name for a wire name, or ``None`` when the key cannot exist. + + Three different absences, and each decided rather than assumed. + + A name the root DECLARES that the generated class does not carry is a + dropped field, and refuses: the constraint grades a position the model + does not have. + + A name no field holds on an OPEN object is a key the document may carry + as an extra. Pydantic puts an extra in ``model_fields_set`` and resolves + it through ``getattr``, so the predicate reads it under its wire name -- + which is how ``get-reporting-status-response``'s nested branch grades + ``failure_kind``, a property only that branch declares and the generator + renders no field for. + + A name no field holds on a CLOSED object is a key the document may not + carry at all -- an AdCP request root that forbids ``creatives`` behind + ``additionalProperties: false`` -- and that is statically decidable. + """ + attribute = shape.attribute(wire_name) + if attribute is not None: + return attribute + at_root = shape is self._root_shape + if at_root and wire_name in self._root_names: + raise UnplaceableConstraintError(self._file, pointer, wire_name) + if not shape.closed: + return wire_name + if at_root and wire_name in self._schema_names: + # Declared by an branch, no field holds it, and the document may not + # carry it either: the constraint grades a position no instance can + # ever have. Unplaceable for the same reason a dropped root field is. + raise UnplaceableConstraintError(self._file, pointer, wire_name) + return None + + def _supplied( + self, + wire_name: str, + shape: DeclaredShape, + pointer: str, + declaration: Any = None, + ) -> Cond: + """``required`` on one name, read as presence on the document SENT. + + ``declaration`` is the node the position declares, which is where + nullability lives. Without one -- a ``dependencies`` map names a + dependent the parser has no declaration in hand for -- the position is + read as nullable, which is membership alone and the weaker of the two + readings. + """ + attribute = self._attribute(wire_name, shape, pointer) + if attribute is None: + return Undeclarable(wire_name) + node = self._declared_node(declaration, wire_name) + return Supplied(attribute, nullable=node is None or permits_null(node)) + + def _declared_node(self, declaration: Any, wire_name: str, depth: int = 0) -> Any: + """The schema node a declaration states for one property, or ``None``. + + A declaration often states a position by reference -- the root declares + ``budget_allocation`` as a ``$ref`` and ``packages`` as an array of + them -- so reading only the inline ``properties`` finds nothing one + level down and every nested ``required`` falls back to the weaker + reading. Following the reference to read ONE property's node is bounded: + no keyword is accounted for here and no condition is compiled, so this + cannot widen the parse. + """ + if not isinstance(declaration, dict) or depth > 4: + return None + properties = declaration.get("properties") + if isinstance(properties, dict) and wire_name in properties: + return properties[wire_name] + reference = declaration.get("$ref") + if isinstance(reference, str): + _document, target = self._resolve_reference(reference) + if target is not None: + return self._declared_node(target, wire_name, depth + 1) + for keyword in ("allOf", "anyOf", "oneOf"): + members = declaration.get(keyword) + if isinstance(members, list): + for member in members: + found = self._declared_node(member, wire_name, depth + 1) + if found is not None: + return found + return None + + +def permits_null(schema: Any) -> bool: + """True when a JSON Schema node explicitly permits ``null``. + + ``required`` and nullability are independent axes: a property listed in + ``required`` whose ``type`` array contains ``"null"`` must be PRESENT and + may be ``null``. Both spellings count -- the ``type: ["string", "null"]`` + array form and a ``oneOf``/``anyOf`` branch of ``{"type": "null"}``. + + This is what separates the two readings of presence. Where a position + permits ``null``, an explicit null is a value the caller sent and the + client keeps it on the wire. Where it does not, the client's + ``exclude_none`` drops it and ``preserve_explicit_nulls`` does not put it + back, so the key is ABSENT from the document that is sent -- and a + constraint that read it as present would refuse a request no seller ever + receives. + """ + if not isinstance(schema, dict): + return False + schema_type = schema.get("type") + if isinstance(schema_type, list) and "null" in schema_type: + return True + for keyword in ("oneOf", "anyOf"): + variants = schema.get(keyword) + if isinstance(variants, list) and any( + isinstance(variant, dict) and variant.get("type") == "null" for variant in variants + ): + return True + return False + + +def _equal(left: Any, right: Any) -> bool: + """Structural equality ignoring keys that assert nothing.""" + if isinstance(left, dict) and isinstance(right, dict): + kept_left = {k: v for k, v in left.items() if k not in NON_ASSERTING_KEYS} + kept_right = {k: v for k, v in right.items() if k not in NON_ASSERTING_KEYS} + if set(kept_left) != set(kept_right): + return False + return all(_equal(kept_left[k], kept_right[k]) for k in kept_left) + if isinstance(left, list) and isinstance(right, list): + return len(left) == len(right) and all( + _equal(a, b) for a, b in zip(left, right, strict=True) + ) + return bool(left == right) and type(left) is type(right) + + +def _conjoin(operands: Iterable[Cond]) -> Cond: + kept: list[Cond] = [operand for operand in operands if not isinstance(operand, Restated)] + if not kept: + return Restated("conjunction") + if len(kept) == 1: + return kept[0] + return AllOf(tuple(kept)) + + +def _follow_pointer(document: Any, fragment: str) -> Any | None: + """Resolve a JSON pointer fragment, honouring ``~0``/``~1`` escapes.""" + node = document + for raw in fragment.lstrip("#").split("/"): + if not raw: + continue + token = raw.replace("~1", "/").replace("~0", "~") + if isinstance(node, list): + try: + node = node[int(token)] + except (ValueError, IndexError): + return None + continue + if not isinstance(node, dict) or token not in node: + return None + node = node[token] + return node + + +def bundle_document_resolver(schema_dir: Path) -> ResolveDocument: + """Resolve a ``$ref``'s document half against a schema bundle directory. + + The bundle spells the same reference two ways -- an absolute + ``https://adcontextprotocol.org/schemas/<version>/<path>`` URL in the plain + schemas and a ``#/$defs/external:<escaped path>`` pointer in the bundled + ``mcp/`` variants -- so matching the longest path suffix that names a file + under ``schema_dir`` covers both without a version table to keep in step. + """ + cache: dict[str, Any | None] = {} + + def resolve(document: str) -> Any | None: + if document not in cache: + cache[document] = _load_bundle_document(schema_dir, document) + return cache[document] + + return resolve + + +def _load_bundle_document(schema_dir: Path, document: str) -> Any | None: + parts = [part for part in document.split("/") if part and not part.endswith(":")] + for start in range(len(parts)): + candidate = schema_dir.joinpath(*parts[start:]) + if candidate.is_file(): + try: + return json.loads(candidate.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return None + + +def read_root_constraints( + schema: dict[str, Any], + declared: DeclaredShape, + *, + branches_render_as_classes: bool, + schema_file: str = "<schema>", + resolve_document: ResolveDocument | None = None, +) -> RootConstraints: + """Read everything a schema root states beyond its merged ``properties``. + + ``declared`` is the wire-to-field map of the class the generator emits for + this root; compiled predicates name attributes, not wire names. + + ``branches_render_as_classes`` says whether the generator rendered each branch that + introduces a property as its own class. On a FLAT root it is ``False``: one + class holds the merged properties, so every branch's content is constraint + content. On a root the generator already split into per-branch classes it is + ``True``, and an branch's own ``required`` and ``properties`` are then + declarations the classes carry. Nothing in the schema distinguishes the two, + so it is not inferred; the caller reads it off the generated module. + + ``resolve_document`` resolves the document half of a ``$ref`` the schema's + own ``$defs`` do not hold. Without one, such a reference refuses -- the + parser does no I/O of its own. + + Raises :class:`UnexpressibleKeywordError` for a keyword the grammar cannot + compile, and :class:`UnplaceableConstraintError` for a constraint naming a + property no class declares. Both abort generation. + """ + return _Parser( + schema_file, + declared, + resolve_document, + branches_render_as_classes=branches_render_as_classes, + ).read(schema) + + +# --------------------------------------------------------------------------- # +# Disjointness: the one prover, used for the emit rule and for ``oneOf`` +# --------------------------------------------------------------------------- # + + +class UndisjointOneOfError(Exception): + """A root ``oneOf`` whose branches this module cannot prove pairwise disjoint. + + ``oneOf`` is exactly-one. A union renders at-least-one, so rendering an + unproven ``oneOf`` as a union would ship anyOf-weaker semantics. Refusing + aborts generation in the same family as :class:`UnexpressibleKeywordError`. + """ + + def __init__(self, schema_file: str, left: str, right: str) -> None: + self.schema_file = schema_file + self.left = left + self.right = right + super().__init__( + f"{schema_file}: oneOf branches {left} and {right} are not provably disjoint, so " + f"rendering them as a union would admit a document satisfying both -- which " + f"oneOf forbids. Either the schema means anyOf, or the prover in " + f"scripts/schema_constraints.py needs the production that separates them." + ) + + +@dataclass(frozen=True) +class _Present: + """``path`` is a member the document carries (negated: does not carry).""" + + path: tuple[str, ...] + positive: bool + + +@dataclass(frozen=True) +class _Admits: + """The value at ``path`` is one of ``values`` (negated: is none of them).""" + + path: tuple[str, ...] + values: frozenset[Any] + positive: bool + + +_Literal = _Present | _Admits + +#: One conjunction of literals, and a disjunction of those: DNF. +_Clause = frozenset[_Literal] + +#: TRUE is the one empty clause; FALSE is no clause at all. +_TRUE: tuple[_Clause, ...] = (frozenset(),) +_FALSE: tuple[_Clause, ...] = () + +#: Past this many clauses the pair is reported unproven rather than expanded. +#: Every branch in the pinned bundle stays three orders of magnitude under it; the +#: cap exists so an adversarial future schema costs a lost proof, not a hang. +_CLAUSE_CEILING = 2048 + + +class _TooBigError(Exception): + """The DNF of one branch exceeded :data:`_CLAUSE_CEILING`.""" + + +def _hashable(value: Any) -> Any: + """A hashable stand-in for a JSON value, for equality only.""" + if isinstance(value, dict): + return ("object", tuple(sorted((key, _hashable(sub)) for key, sub in value.items()))) + if isinstance(value, list): + return ("array", tuple(_hashable(element) for element in value)) + return value + + +def _prefix(clauses: tuple[_Clause, ...], name: str) -> tuple[_Clause, ...]: + """Re-root every literal's path under ``name``.""" + return tuple( + frozenset( + ( + _Present((name, *literal.path), literal.positive) + if isinstance(literal, _Present) + else _Admits((name, *literal.path), literal.values, literal.positive) + ) + for literal in clause + ) + for clause in clauses + ) + + +def _product(left: tuple[_Clause, ...], right: tuple[_Clause, ...]) -> tuple[_Clause, ...]: + """Conjunction in DNF: every pairing, contradictory pairings dropped.""" + clauses = tuple( + merged for one in left for other in right if _satisfiable(merged := one | other) + ) + if len(clauses) > _CLAUSE_CEILING: + raise _TooBigError + return clauses + + +def _union(parts: Iterable[tuple[_Clause, ...]]) -> tuple[_Clause, ...]: + clauses: list[_Clause] = [] + for part in parts: + clauses.extend(part) + if len(clauses) > _CLAUSE_CEILING: + raise _TooBigError + return tuple(clauses) + + +def _satisfiable(clause: _Clause) -> bool: + """Whether one conjunction of literals can hold. + + Two decisions, both first-order. A member cannot be both carried and not + carried. And a value cannot lie in two value sets with no member in common + -- sound because a value literal only ever reaches a clause alongside the + presence literal for its own path, so an absent member never clashes. + """ + present: dict[tuple[str, ...], bool] = {} + for literal in clause: + if not isinstance(literal, _Present): + continue + if present.setdefault(literal.path, literal.positive) is not literal.positive: + return False + admitted: dict[tuple[str, ...], set[Any]] = {} + for literal in clause: + if not isinstance(literal, _Admits) or not literal.positive: + continue + current = admitted.get(literal.path) + admitted[literal.path] = ( + set(literal.values) if current is None else current & literal.values + ) + for literal in clause: + if not isinstance(literal, _Admits) or literal.positive: + continue + if literal.path in admitted: + admitted[literal.path] -= literal.values + return all(values for values in admitted.values()) + + +def _clauses( + condition: Cond, negated: bool = False +) -> tuple[_Clause, ...]: # noqa: C901, PLR0911, PLR0912 + """One ``Cond`` in disjunctive normal form over presence and value literals. + + Every node kind the prover has no literal for compiles to TRUE, under + negation as well. That WEAKENS the branch, and a refutation of a weaker + conjunction refutes the stronger one, so an unmodelled kind can only cost a + proof -- never manufacture one. + """ + if isinstance(condition, Restated): + return _FALSE if negated else _TRUE + if isinstance(condition, Supplied): + return (frozenset({_Present((condition.name,), not negated)}),) + if isinstance(condition, Undeclarable): + # False of every instance of a class that cannot carry the key. + return _TRUE if negated else _FALSE + if isinstance(condition, Not): + return _clauses(condition.operand, not negated) + if isinstance(condition, AllOf): + if negated: + return _union(_clauses(operand, True) for operand in condition.operands) + clauses = _TRUE + for operand in condition.operands: + clauses = _product(clauses, _clauses(operand)) + return clauses + if isinstance(condition, (AnyOf, OneOf)): + # ``oneOf`` is read as its ``anyOf`` weakening here, which is the safe + # direction: a bigger satisfying set, so a refutation still holds. + if negated: + clauses = _TRUE + for operand in condition.operands: + clauses = _product(clauses, _clauses(operand, True)) + return clauses + return _union(_clauses(operand) for operand in condition.operands) + if isinstance(condition, IfThen): + condition_true = _clauses(condition.condition) + condition_false = _clauses(condition.condition, True) + consequent = _TRUE if condition.consequent is None else _clauses(condition.consequent) + alternative = _TRUE if condition.alternative is None else _clauses(condition.alternative) + if negated: + negated_consequent = ( + _FALSE if condition.consequent is None else _clauses(condition.consequent, True) + ) + negated_alternative = ( + _FALSE if condition.alternative is None else _clauses(condition.alternative, True) + ) + return _union( + ( + _product(condition_true, negated_consequent), + _product(condition_false, negated_alternative), + ) + ) + return _union( + (_product(condition_true, consequent), _product(condition_false, alternative)) + ) + if isinstance(condition, PropSatisfies): + # ``properties`` grades a present member only, so the node is "absent, + # or present and satisfying" -- which is what makes a value literal + # always arrive with its own presence literal. + inner = _prefix(_clauses(condition.operand), condition.name) + if negated: + return _product( + (frozenset({_Present((condition.name,), True)}),), + _prefix(_clauses(condition.operand, True), condition.name), + ) + return _union( + ( + (frozenset({_Present((condition.name,), False)}),), + _product((frozenset({_Present((condition.name,), True)}),), inner), + ) + ) + if isinstance(condition, ValueConst): + return (frozenset({_Admits((), frozenset({_hashable(condition.value)}), not negated)}),) + if isinstance(condition, ValueEnum): + values = frozenset(_hashable(value) for value in condition.values) + return (frozenset({_Admits((), values, not negated)}),) + # Every other production bounds a value rather than naming it. Weakening + # them to TRUE keeps the prover first-order; see the docstring. + return _TRUE + + +def branch_conditions(constraints: RootConstraints) -> tuple[Cond, ...]: + """What a document satisfying each branch must satisfy, conjuncts included. + + A root's conjuncts hold of every document the root accepts, so each branch's + real condition is the branch conjoined with them. Distributing them is what + turns three both-present bans into disjointness rather than leaving them as + a check beside branches that overlap without them: ``webhook-challenge-response`` + bans ``challenge`` and ``token`` together at the root, and its two branches + require one each. + """ + return tuple( + _conjoin((branch.condition, *constraints.conjuncts)) for branch in constraints.branches + ) + + +def provably_disjoint(left: Cond, right: Cond) -> bool: + """Whether no document can satisfy both conditions. + + Sound and deliberately incomplete: it decides presence and named-value + clashes, which is what the branches of the pinned bundle are separated by, and + reports anything else unproven. + """ + try: + return all( + not _satisfiable(one | other) for one in _clauses(left) for other in _clauses(right) + ) + except _TooBigError: + return False + + +def partitions_by_requiredness(constraints: RootConstraints) -> bool: + """Whether the branches partition the document space, so each branch is a type. + + Two conditions. Every branch must require something: without ``required`` an + branch grades only members that are present, so it is vacuously satisfiable and + a document mentioning none of its fields satisfies it + (``postal-country-system``'s ten branches, all satisfied by a document with no + ``country``). And the branches must be provably pairwise disjoint, which is what + makes the class a parsed document comes back as schema information rather + than branch order (``control-media-buy-request``'s twelve branches each require + one field and permit the other eleven -- "name at least one field to + change", one shape, where an ``isinstance`` on the returned class reads the + order of a JSON file and nothing about the document). + + The approved rule reads "partition by requiredness into DISTINCT, mutually + exclusive field sets", and distinctness of the required sets is deliberately + not a third condition here. Measured over the pinned bundle it decides + nothing -- no root has two branches with the same required set that the prover + separates -- and as a rule it would refuse a const-discriminated pair that + requires the same group, which is a partition the design asks to emit. What + it was guarding is that two branches needing the same rung-3 name cannot both be + named, and the naming rule refuses that where it arises. + """ + branches = constraints.branches + if len(branches) < 2: + return False + if any(not branch.required for branch in branches): + return False + conditions = branch_conditions(constraints) + return all( + provably_disjoint(conditions[index], conditions[other]) + for index in range(len(conditions)) + for other in range(index + 1, len(conditions)) + ) diff --git a/scripts/task_message_lattice.py b/scripts/task_message_lattice.py index e78a47fe4..0d9722572 100644 --- a/scripts/task_message_lattice.py +++ b/scripts/task_message_lattice.py @@ -28,6 +28,11 @@ import re import sys import typing + +try: + from generated_names import generated_root_class_name +except ModuleNotFoundError: # Imported as ``scripts.task_message_lattice`` in tests. + from scripts.generated_names import generated_root_class_name # type: ignore[assignment] from pathlib import Path _CANONICAL_REF = re.compile(r"^https://adcontextprotocol\.org/schemas/[^/]+/(.+)$") @@ -49,19 +54,6 @@ def registry_ref_to_path(ref: str) -> Path: raise RuntimeError(f"task registry $ref is not absolute: {ref!r}") -def mangle_schema_title(title: str) -> str: - """The class name datamodel-code-generator derives from a schema ``title``. - - Measured against all 154 task-message schemas at pin 3.2.1: this reproduces the - generated root class (or union alias) name for 154 of 154. The acronym collapse is the - part a filename-derived guess gets wrong in both directions -- "Get AdCP Capabilities - Request" becomes ``GetAdcpCapabilitiesRequest``, and - ``list-creative-formats-request.json`` does NOT become ``ListCreativeFormatsRequest`` - because its title names the agent variant. - """ - return "".join(word.capitalize() for word in re.split(r"[^0-9a-zA-Z]+", title) if word) - - def task_registry_messages(schema_dir: Path) -> list[tuple[str, str, Path]]: """Every task message in the pinned bundle: (task, "request"|"response", schema path). @@ -119,12 +111,12 @@ def module_top_level(tree: ast.Module) -> tuple[dict[str, ast.ClassDef], dict[st return classes, aliases -def _alias_arms_expr(node: ast.expr) -> ast.expr: +def _alias_branches_expr(node: ast.expr) -> ast.expr: """The type an alias names, with any ``Annotated[...]`` metadata dropped. - A composing root is emitted as ``Annotated[A | B, Field(...)]``, so the arms are + A composing root is emitted as ``Annotated[A | B, Field(...)]``, so the branches are the first subscript element and nothing else. Walking the whole expression reads - ``Annotated`` -- and the ``Field`` call beside it -- as union arms, and then fails + ``Annotated`` -- and the ``Field`` call beside it -- as union branches, and then fails closed on the first one because no module defines a class by that name. """ while ( @@ -144,7 +136,7 @@ def root_class_names(schema_rel: Path, module: Path, tree: ast.Module, root: str """The class(es) a task message can validate into, in the module that defines them. A plain root is one class. A union root is a type alias, and the alias is not a class -- - so the marker lands on each member arm, which is what makes + so the marker lands on each member branch, which is what makes ``isinstance(resp, AdcpResponse)`` hold for every value the task can answer with. Nested aliases are followed. Fails closed: a root that resolves to neither a class nor an alias, or an alias naming something this module does not define, is an error with @@ -159,7 +151,9 @@ def root_class_names(schema_rel: Path, module: Path, tree: ast.Module, root: str f"class nor a type alias named {root!r}" ) pending = [ - node.id for node in ast.walk(_alias_arms_expr(aliases[root])) if isinstance(node, ast.Name) + node.id + for node in ast.walk(_alias_branches_expr(aliases[root])) + if isinstance(node, ast.Name) ] resolved: list[str] = [] seen: set[str] = set() @@ -173,16 +167,16 @@ def root_class_names(schema_rel: Path, module: Path, tree: ast.Module, root: str elif name in aliases: pending.extend( node.id - for node in ast.walk(_alias_arms_expr(aliases[name])) + for node in ast.walk(_alias_branches_expr(aliases[name])) if isinstance(node, ast.Name) ) else: raise RuntimeError( - f"{schema_rel.as_posix()}: union arm {name!r} of {root} is not defined in " + f"{schema_rel.as_posix()}: union branch {name!r} of {root} is not defined in " f"{module.name}; the marker pass cannot reach it (one mechanism only)" ) if not resolved: - raise RuntimeError(f"{schema_rel.as_posix()}: {root} resolved to no arm classes") + raise RuntimeError(f"{schema_rel.as_posix()}: {root} resolved to no branch classes") return resolved @@ -209,7 +203,7 @@ def task_message_roots( kind, schema_rel, module, - root_class_names(schema_rel, module, tree, mangle_schema_title(title)), + root_class_names(schema_rel, module, tree, generated_root_class_name(title)), ) ) return rows @@ -220,7 +214,7 @@ def task_message_roots( # -------------------------------------------------------------------------------------- -def _arms(obj: object) -> list[type]: +def _branches(obj: object) -> list[type]: """A union alias's member classes, or the single class itself.""" members = list(typing.get_args(obj)) or [obj] return [m for m in members if isinstance(m, type)] @@ -230,7 +224,7 @@ def measure(schema_dir: Path, output_dir: Path, package_root: Path) -> int: """Print the ancestry table and return a process exit code. Ancestry means EVERY root class the message can validate into descends from the - ancestor -- all arms, not any arm. A union whose submitted arm lacks the envelope + ancestor -- all branches, not any branch. A union whose submitted branch lacks the envelope cannot answer the envelope question for every value the task returns, so counting it as composed overstates what a caller may rely on. """ @@ -250,7 +244,7 @@ def measure(schema_dir: Path, output_dir: Path, package_root: Path) -> int: for task, kind, schema_rel, module, names in rows: dotted = ".".join(module.relative_to(package_root).with_suffix("").parts) imported = importlib.import_module(dotted) - classes = [c for name in names for c in _arms(getattr(imported, name))] + classes = [c for name in names for c in _branches(getattr(imported, name))] resolved.append((task, kind, schema_rel.as_posix(), classes)) failures = 0 diff --git a/scripts/union_branch_names.py b/scripts/union_branch_names.py new file mode 100644 index 000000000..64833df9b --- /dev/null +++ b/scripts/union_branch_names.py @@ -0,0 +1,467 @@ +"""Schema-derived names for union branches (ARCH-ONE-SURFACE §3). + +The generator throws a union branch's identity away. ``create-media-buy-response.json`` +titles its three branches ``CreateMediaBuySuccess`` / ``CreateMediaBuyError`` / +``CreateMediaBuySubmitted``, and what ships is ``CreateMediaBuyResponse1`` / ``2`` +/ ``3``. Verified directly, not assumed, on both candidates: feeding +datamodel-code-generator 0.64.0 a three-branch ``oneOf`` whose branches all carry +``title`` still produces ``DemoResponse1/2/3``, and this repo's own response +emitter numbers its branches by position too. A positional name is the drift channel the whole +one-surface design is about, because inserting an branch upstream silently repoints +an existing public name. + +This module answers one question for one schema node — *what is this branch called* — +and nothing else. It does not rename anything and it does not touch the generated +tree, which keeps the naming rule auditable on its own and keeps whoever consumes +it free to apply it wherever the name is actually chosen. Two consumers: + +* the custom response emitter in ``scripts/post_generate_fixes.py``, which is + where the numbers come from for every response union — literally + ``class_names = [f"{self.base}{index}" for index in range(1, len(branches) + 1)]``. + Measured: of the schemas with a root union, the generator collapses most into a + single class and the numbered branches are the emitter's own, so the naming decision + has exactly one site and it is a line of this repo's code, not the generator's. +* ``main()``, which prints the classification of every root-level branch in the + pinned bundle — the residual list ARCH-ONE-SURFACE §5 Step 3 asks the pass's + first run to emit. + +A schema transform was measured as the alternative and works on the generator in +isolation: lifting each branch into ``definitions/<DerivedName>`` and leaving a +``$ref`` makes 0.64.0 emit ``DemoSuccess`` / ``DemoError`` / ``DemoSubmitted`` +instead of ``DemoResponse1/2/3``. It is not the mechanism used for responses only +because the emitter, not the generator, names those. + +**Step zero — classify before naming.** An branch that introduces no property the +base schema does not already declare is a VALIDATION branch: its content is +``required``, ``not``, or a constraint refinement. It must mint no class, because +a public class for it would name something no buyer sends. Only then do the +naming rungs run, in order, first one that holds: + +1. the node's ``title``; +2. the discriminator ``const`` — parent + PascalCase(const value); +3. the FULL required set — parent + PascalCase(each sorted required key). Not the + set-difference against siblings: the difference is empty exactly where one branch + is the conjunction of two others, and the bundle contains that case + (``a2ui/bound-value.json`` has five branches requiring ``{literalString}``, + ``{literalNumber}``, ``{literalBoolean}``, ``{path}``, + ``{literalString, path}`` — branch 4 overlaps branches 0 and 3, so its difference is + empty while the full sets are all distinct); +4. fail closed. No counter, no ledger of tolerated numbers: a counter quietly + becomes load-bearing again. +""" + +from __future__ import annotations + +import json +import re +import sys +from collections import Counter +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +#: Branch kinds. ``ref`` already names the shared class it selects; ``scalar`` and +#: ``validation`` mint no class; ``type`` is the population the rungs name. +BranchKind = Literal["ref", "scalar", "validation", "type"] + +#: Which rung produced a name, or ``"none"`` for a rung-4 refusal. +Rung = Literal["title", "const", "required-set", "none"] + + +def pascal_case(value: object) -> str: + """PascalCase a schema string while PRESERVING interior capitals. + + Distinct from :func:`scripts.generated_names.generated_root_class_name`, which + is the generator's ROOT naming and lower-cases the tail of a word + (``Get AdCP Capabilities Request`` -> ``GetAdcpCapabilitiesRequest``). A + branch title is frequently already written in PascalCase + (``CreateMediaBuySuccess``), and that rule would mangle it to + ``Createmediabuysuccess``. Two inputs, two specifications, two functions. + + And this one is NOT a restatement of a generator rule, which is why it stays + here while the two that were moved to ``scripts/generated_names.py``: the + generator throws a branch's identity away, so a branch's name is chosen by + this repo's own emitter and there is no generator answer to ask for. + """ + words = [word for word in re.split(r"[^0-9a-zA-Z]+", str(value)) if word] + return "".join(word[:1].upper() + word[1:] for word in words) + + +def _is_bare_ref(branch: dict[str, Any]) -> bool: + """A ``$ref`` branch already names the shared class it selects.""" + return "$ref" in branch and not (set(branch) - {"$ref", "title", "description"}) + + +#: Keywords by which an branch says something about a schema's shape. +_STRUCTURAL = frozenset({"properties", "$ref", "allOf", "oneOf", "anyOf"}) + +#: Keywords by which an branch constrains a shape it does not declare. +_CONSTRAINING = frozenset({"required", "not", "dependencies", "dependentRequired", "if"}) + + +def _declares_a_scalar_value(branch: dict[str, Any]) -> bool: + """The branch is a value, not an object type: a non-object type, a const, an enum.""" + declared = branch.get("type") + if isinstance(declared, str) and declared != "object": + return True + if isinstance(declared, list) and "object" not in declared: + return True + return "const" in branch or "enum" in branch + + +def _introduced_properties(branch: dict[str, Any], base_properties: dict[str, Any]) -> set[str]: + own = branch.get("properties") + if not isinstance(own, dict): + return set() + return set(own) - set(base_properties) + + +def classify(branch: dict[str, Any], base_properties: dict[str, Any]) -> BranchKind: + """Classify one root-level union branch. See the module docstring's step zero. + + Order matters, and the bundle proves it: ``create-media-buy-request.json`` + anyOf[2] declares only ``required`` and ``not`` — no ``type``, no + ``properties`` — so a "nothing structural, therefore a scalar" fallback + claims it, when it is the purest validation branch in the tree. A scalar is + recognized by what it POSITIVELY declares; a constraining branch is recognized + by its constraint keywords; only a genuinely empty branch falls through. + """ + if _is_bare_ref(branch): + return "ref" + if _declares_a_scalar_value(branch): + return "scalar" + if _introduced_properties(branch, base_properties): + return "type" + if set(branch) & (_CONSTRAINING | _STRUCTURAL): + return "validation" + return "scalar" + + +def _discriminator_const(branch: dict[str, Any]) -> object | None: + """The single pinned value a property of this branch carries, if any.""" + properties = branch.get("properties") + if not isinstance(properties, dict): + return None + for node in properties.values(): + if not isinstance(node, dict): + continue + if "const" in node: + const: object = node["const"] + return const + enum = node.get("enum") + if isinstance(enum, list) and len(enum) == 1: + single: object = enum[0] + return single + return None + + +def _parent_name(document: dict[str, Any], relative: Path) -> str: + title = document.get("title") + if isinstance(title, str) and title.strip(): + return pascal_case(title) + return pascal_case(relative.stem) + + +def _candidate( + document: dict[str, Any], + branch: dict[str, Any], + rung: Rung, + relative: Path, +) -> str | None: + """One rung's candidate name for one branch, or ``None`` if the rung is silent.""" + if rung == "title": + title = branch.get("title") + if isinstance(title, str) and title.strip(): + return pascal_case(title) + return None + parent = _parent_name(document, relative) + if rung == "const": + const = _discriminator_const(branch) + return None if const is None else f"{parent}{pascal_case(const)}" + if rung == "required-set": + required = branch.get("required") + if not isinstance(required, list) or not required: + return None + keys = sorted(str(key) for key in required) + return parent + "".join(pascal_case(key) for key in keys) + return None + + +#: The rungs, in the order ARCH-ONE-SURFACE §3 sets them. +_RUNGS: tuple[Rung, ...] = ("title", "const", "required-set") + + +def derive( + document: dict[str, Any], + branches: list[Any], + index: int, + relative: Path, +) -> tuple[str | None, Rung]: + """The branch's name by the first rung that holds, or ``(None, "none")``. + + A rung "holds" only when it yields a name that is UNIQUE among the siblings + at that same rung. The uniqueness test is not an extra safety belt bolted + onto rung 3 — it is what makes every rung position-free, and the bundle needs + it on rung 2 as well: ``core/audience-selector.json`` has three branches all + pinning ``type: "signal"`` (binary, categorical and numeric signals), so the + const rung names them identically and must fall through to the required set, + which separates them. Found by the fail-closed rule rather than by reading. + """ + branch = branches[index] + siblings = [ + sibling + for position, sibling in enumerate(branches) + if position != index and isinstance(sibling, dict) and sibling.get("type") != "null" + ] + for rung in _RUNGS: + mine = _candidate(document, branch, rung, relative) + if mine is None: + continue + if any(_candidate(document, sibling, rung, relative) == mine for sibling in siblings): + continue + return mine, rung + return None, "none" + + +@dataclass(frozen=True) +class BranchName: + """One root-level union branch, classified and (if it is a type) named.""" + + schema: str + keyword: str + index: int + kind: BranchKind + name: str | None + rung: Rung + + @property + def pointer(self) -> str: + return f"{self.schema}#/{self.keyword}/{self.index}" + + +class UnnameableBranchError(RuntimeError): + """A type branch with no derivable name. Fail closed, naming the schema node.""" + + +def classify_document(document: dict[str, Any], relative: Path) -> list[BranchName]: + """Classify and name every root-level, non-null union branch of one schema.""" + results: list[BranchName] = [] + base_properties = document.get("properties") + if not isinstance(base_properties, dict): + base_properties = {} + for keyword in ("oneOf", "anyOf"): + branches = document.get(keyword) + if not isinstance(branches, list): + continue + for index, branch in enumerate(branches): + if not isinstance(branch, dict) or branch.get("type") == "null": + continue + kind = classify(branch, base_properties) + if kind != "type": + results.append(BranchName(relative.as_posix(), keyword, index, kind, None, "none")) + continue + name, rung = derive(document, branches, index, relative) + results.append(BranchName(relative.as_posix(), keyword, index, kind, name, rung)) + _refuse_collisions(results, relative) + return results + + +def _refuse_collisions(results: list[BranchName], relative: Path) -> None: + """Two branches of one schema must not derive the same name.""" + taken: dict[str, BranchName] = {} + for item in results: + if item.name is None: + continue + clash = taken.get(item.name) + if clash is not None: + raise UnnameableBranchError( + f"{relative.as_posix()}: {item.pointer} and {clash.pointer} both derive " + f"the name {item.name!r}; the rungs must name a schema node uniquely" + ) + taken[item.name] = item + + +def schema_files(schema_dir: Path) -> list[Path]: + """Every schema the pipeline reads: ``bundled/`` and ``mcp/`` are excluded. + + ``bundled/`` is self-contained by design and ``mcp/`` is a per-profile tree + whose duplicates inflate every count taken over it. + """ + return [ + path + for path in sorted(schema_dir.rglob("*.json")) + if "bundled" not in path.relative_to(schema_dir).parts + and "mcp" not in path.relative_to(schema_dir).parts + ] + + +def classify_bundle(schema_dir: Path) -> list[BranchName]: + """Classify every root-level union branch in the bundle.""" + results: list[BranchName] = [] + for path in schema_files(schema_dir): + document = json.loads(path.read_text()) + if not isinstance(document, dict): + continue + results.extend(classify_document(document, path.relative_to(schema_dir))) + return results + + +def tally(results: list[BranchName]) -> Counter[str]: + counts: Counter[str] = Counter() + counts["root branches"] = len(results) + for item in results: + if item.kind != "type": + counts[f"mints no class ({item.kind})"] += 1 + elif item.rung == "none": + counts["rung 4: FAIL CLOSED"] += 1 + else: + counts[f"rung: {item.rung}"] += 1 + return counts + + +def unnameable(results: list[BranchName]) -> list[BranchName]: + return [item for item in results if item.kind == "type" and item.name is None] + + +def main(argv: list[str] | None = None) -> int: + """Print the classification of the pinned bundle's root-level union branches.""" + argv = list(sys.argv[1:] if argv is None else argv) + schema_dir = Path(argv[0]) if argv else Path("schemas/cache/3.2") + results = classify_bundle(schema_dir) + counts = tally(results) + print(f"bundle: {schema_dir} ({len(schema_files(schema_dir))} schema files)") + for key in sorted(counts): + print(f" {counts[key]:5d} {key}") + refused = unnameable(results) + print(f"\nunnameable type branches: {len(refused)}") + for item in refused: + print(f" {item.pointer}") + derived = [item for item in results if item.name] + print(f"\nnames derived (first 40 of {len(derived)}):") + for item in derived[:40]: + print(f" {item.rung:13s} {item.pointer:68s} -> {item.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + +# --------------------------------------------------------------------------- # +# Branch classes: the same rungs, over the records the constraint reader built +# --------------------------------------------------------------------------- # +# +# Two amendments the branch-class design forces, both mechanical. +# +# STEP ZERO INVERTS. The rule above says an branch introducing no new property is a +# validation branch and must mint no class -- correct while a flat root class exists +# for it to refine. Where the branch-class emitter runs, that class is gone: the +# branch IS the request shape, so on a flat-root combinator schema whose branches +# partition, every branch mints a class. ``classify`` is therefore not consulted on +# that path. +# +# THE TITLE RUNG TAKES THE PARENT PREFIX TOO. A response branch's title already +# carries its stem (``CreateMediaBuySuccess``), but a request branch's title is +# prose: a bare ``CommittedProposalExecution`` on the flat surface does not say +# what it is. Parent + PascalCase(title) is as mechanical as parent + const, and +# a title that already starts with the parent name is not doubled. + + +def _record_candidate(parent: str, branch: Any, rung: Rung) -> str | None: + """One rung's candidate name for one ``Branch`` record.""" + if rung == "title": + if not branch.title or not str(branch.title).strip(): + return None + return _prefixed(parent, pascal_case(branch.title)) + if rung == "const": + if branch.discriminator is None: + return None + return f"{parent}{pascal_case(branch.discriminator[1])}" + if rung == "required-set": + if not branch.required: + return None + return parent + "".join(pascal_case(key) for key in sorted(branch.required)) + return None + + +#: Splits a PascalCase identifier into its words, acronyms kept whole. +_WORDS = re.compile(r"[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+") + +#: How many leading words a title must share with the parent to be read as +#: already carrying its stem. Two, because one is a coincidence a prose title +#: hits often ("Create" against CreateMediaBuyRequest) and three would miss +#: the two-word stems the bundle writes. +_STEM_WORDS = 2 + + +def _prefixed(parent: str, derived: str) -> str: + """``parent + derived``, unless the title already carries the parent's stem. + + A response branch's title is written with its stem -- ``CreateMediaBuySuccess`` + under ``CreateMediaBuyResponse`` -- and prefixing it again spells the stem + twice. A request branch's title is prose (``Committed proposal execution``) and + says nothing about which request it is, so it takes the prefix. The test is + the two names' shared leading WORDS, which needs no list of suffixes to + strip and no table of stems: ``Create Media Buy`` is shared, ``Committed`` + shares nothing. + """ + parent_words = _WORDS.findall(parent) + derived_words = _WORDS.findall(derived) + shared = 0 + for mine, theirs in zip(parent_words, derived_words): + if mine != theirs: + break + shared += 1 + return derived if shared >= _STEM_WORDS else f"{parent}{derived}" + + +def branch_class_names(parent: str, branches: Sequence[Any], schema: str) -> tuple[str, ...]: + """One class name per branch, from the branch's own node identity. + + ``branches`` are ``scripts.schema_constraints.Branch`` records, so this consults no + schema node of its own -- the reader already read them, and a second reading + is the drift channel the one-reader rule exists to close. + + The first rung that holds for an branch names it, and a rung "holds" only when + its candidate is unique among the siblings at the same rung: a shared title + or a shared const names nothing, and must fall through to the required set. + An branch no rung names raises :class:`UnnameableBranchError` rather than taking a + position -- there is no counter and no ledger of tolerated numbers. + """ + names: list[str] = [] + for index, branch in enumerate(branches): + siblings = [other for position, other in enumerate(branches) if position != index] + chosen: str | None = None + for rung in _RUNGS: + candidate = _record_candidate(parent, branch, rung) + if candidate is None: + continue + if any(_record_candidate(parent, other, rung) == candidate for other in siblings): + continue + chosen = candidate + break + if chosen is None: + raise UnnameableBranchError( + f"{schema}#/{branch.pointer}: no rung names this branch -- it carries no " + f"title, no discriminating const and no required set unique among its " + f"siblings. A class named by its position is the drift channel this " + f"rule exists to close, so generation refuses instead." + ) + names.append(chosen) + _refuse_record_collisions(parent, branches, names, schema) + return tuple(names) + + +def _refuse_record_collisions( + parent: str, branches: Sequence[Any], names: Sequence[str], schema: str +) -> None: + taken: dict[str, str] = {} + for branch, name in zip(branches, names, strict=True): + clash = taken.get(name) + if clash is not None: + raise UnnameableBranchError( + f"{schema}: {branch.pointer} and {clash} both derive the name {name!r}; " + f"the rungs must name a schema node uniquely" + ) + taken[name] = branch.pointer diff --git a/src/adcp/__main__.py b/src/adcp/__main__.py index d95cc51fc..ed93365c6 100644 --- a/src/adcp/__main__.py +++ b/src/adcp/__main__.py @@ -448,8 +448,21 @@ async def _dispatch_tool(client: ADCPClient, tool_name: str, payload: dict[str, return await method(request) except ValidationError as e: # User-friendly error for invalid payloads + from adcp.types.error_narrowing import narrow_union_errors + error_details = [] - for error in e.errors(): + # Narrow first, as every other consumer of a union ValidationError + # does. Iterating the raw errors let a split root's wholesale refusal + # ("packages is not accepted by this request shape", at no field at + # all) and every branch's duplicate reach the CLI alongside the real + # one. + # + # No projection of `loc` here: the public `narrow_union_errors` already + # returns each error with `without_schema_labels` applied to its loc, + # which is why `server.mcp_tools` calls it bare too. Projecting again + # at the call site is the pattern that was deleted from + # `error_narrowing` for deciding membership by spelling. + for error in narrow_union_errors(e.errors()): field = ".".join(str(loc) for loc in error["loc"]) msg = error["msg"] error_details.append(f" - {field}: {msg}") diff --git a/src/adcp/canonical_formats/compat_helpers.py b/src/adcp/canonical_formats/compat_helpers.py index c0fdb70d0..a9a563043 100644 --- a/src/adcp/canonical_formats/compat_helpers.py +++ b/src/adcp/canonical_formats/compat_helpers.py @@ -28,6 +28,27 @@ r"^display_(?P<width>[1-9][0-9]*)x(?P<height>[1-9][0-9]*)(?:_image)?$" ) +#: The parameters a format reference carries beyond its ``(agent_url, id)`` +#: identity, derived from the model instead of listed. +#: +#: A hand-written list here is the defect that dropped ``pixel_ratio``: three +#: of the four parameters were copied by name, so a creative that claimed no +#: pixel density compared equal to a product format that requires 2x, two +#: references declaring *conflicting* densities compared equal, and the +#: legacy-size upgrade discarded the density it was handed. Deriving the set +#: means a parameter added to ``core/format-id.json`` — and so to the +#: generated model — is compared without being named here. +_PARAMETER_FIELDS: tuple[str, ...] = tuple(sorted(set(FormatId.model_fields) - {"agent_url", "id"})) + +#: Parameters the schema gives a value to when they are absent. +#: +#: ``core/format-id.json`` describes omitting ``pixel_ratio`` as selecting +#: "the backward-compatible 1x variant", which is a value, not silence. An +#: omitted width or duration means "template, unparameterized"; an omitted +#: density means 1x. So a product fixed at 2x refuses a request that says +#: nothing, while one fixed at 1x still serves it. +_IMPLIED_WHEN_ABSENT: dict[str, float] = {"pixel_ratio": 1.0} + def _coerce_format_id( value: FormatIdInput, @@ -74,11 +95,14 @@ def _upgrade_legacy_format_id( return FormatId.model_validate( { + # Every parameter the reference carried, then the two the legacy + # size ID itself states. Reparameterizing the ID must not drop a + # parameter the caller supplied alongside it. + **{field: getattr(fid, field) for field in _PARAMETER_FIELDS}, "agent_url": str(fid.agent_url), "id": "display_image", "width": int(match.group("width")), "height": int(match.group("height")), - "duration_ms": fid.duration_ms, } ) @@ -110,8 +134,9 @@ def formats_are_equivalent( Both inputs are first passed through :func:`upgrade_legacy_format_id`. Declared parameters must not conflict, but an omitted parameter on either side is treated as unspecified rather than a mismatch. Use - :func:`format_is_supported` for product/capability gating where a - supported fixed size or duration requires the request to state that value. + :func:`format_is_supported` for product/capability gating, where a + supported fixed size, duration or pixel density requires the request to + state that value. """ left = _upgrade_legacy_format_id(a, default_agent_url=default_agent_url, param="a") right = _upgrade_legacy_format_id(b, default_agent_url=default_agent_url, param="b") @@ -120,7 +145,7 @@ def formats_are_equivalent( if left.id != right.id: return False - for field in ("width", "height", "duration_ms"): + for field in _PARAMETER_FIELDS: left_value = getattr(left, field) right_value = getattr(right, field) if left_value is not None and right_value is not None and left_value != right_value: @@ -139,8 +164,14 @@ def format_is_supported( This is intentionally stricter than :func:`formats_are_equivalent`. A broad supported format such as ``display_image`` accepts a specific request such as ``display_image`` 300x250, but a fixed supported product - format requires the request to provide and match every fixed parameter - (``width``, ``height``, and ``duration_ms``). + format requires the request to provide and match every parameter the + supported side fixes — every parameter ``core/format-id.json`` declares, + not a chosen few. + + ``pixel_ratio`` is the one parameter whose absence carries a value rather + than meaning "unspecified": the schema describes omitting it as selecting + the backward-compatible 1x variant. A product fixed at 2x therefore + refuses a request that omits the field, and one fixed at 1x accepts it. """ req = _upgrade_legacy_format_id( requested, default_agent_url=default_agent_url, param="requested" @@ -151,11 +182,14 @@ def format_is_supported( if not formats_are_equivalent(req, sup, default_agent_url=default_agent_url): return False - for field in ("width", "height", "duration_ms"): + for field in _PARAMETER_FIELDS: supported_value = getattr(sup, field) if supported_value is None: continue - if getattr(req, field) != supported_value: + requested_value = getattr(req, field) + if requested_value is None: + requested_value = _IMPLIED_WHEN_ABSENT.get(field) + if requested_value != supported_value: return False return True diff --git a/src/adcp/canonical_formats/format_options.py b/src/adcp/canonical_formats/format_options.py index 9cb8b0dfa..9fd46b406 100644 --- a/src/adcp/canonical_formats/format_options.py +++ b/src/adcp/canonical_formats/format_options.py @@ -25,7 +25,7 @@ from collections.abc import Iterable -from adcp.canonical_formats.identity import canonicalize_agent_url +from adcp.canonical_formats.identity import canonicalize_agent_url, declared_v1_format_refs from adcp.types import CanonicalFormatKind, Error, Format from adcp.types.legacy import LegacyFormatId @@ -182,7 +182,7 @@ def find_declaration_by_v1_format_id( target_url = canonicalize_agent_url(format_id.agent_url) target_id = format_id.id for decl in format_options: - refs = decl.legacy_format_refs + refs = declared_v1_format_refs(decl) for ref in refs: ref_url = canonicalize_agent_url(ref.agent_url) if ref_url == target_url and ref.id == target_id: diff --git a/src/adcp/canonical_formats/identity.py b/src/adcp/canonical_formats/identity.py index 8d0f4f1f3..9af18e294 100644 --- a/src/adcp/canonical_formats/identity.py +++ b/src/adcp/canonical_formats/identity.py @@ -76,3 +76,39 @@ def coerce_format_id(value: object) -> LegacyFormatId: if isinstance(agent_url, AnyUrl): body["agent_url"] = str(agent_url) return LegacyFormatId.model_validate(body) + + +#: The two spellings a product format declaration uses for its v1 refs, in the +#: order they are probed. They are disjoint: no type in this package declares +#: both, so the first hit is the only hit. +_V1_REF_SPELLINGS = ("v1_format_ref", "legacy_format_refs") + + +def declared_v1_format_refs(declaration: object) -> tuple[LegacyFormatId, ...]: + """Return the v1 refs *declaration* asserts, in whichever spelling it uses. + + Two types in this package are called a product format declaration and they + name this field differently: + + * the sixteen generated ``ProductFormatDeclaration`` arms declare + ``v1_format_ref`` as a real field, which is what + ``domains.core.product.Product.format_options`` holds; + * ``canonical_creative.Format`` exposes ``legacy_format_refs``, a property + over private state populated from ``v1_format_ref`` at construction. + + Reading either spelling directly raises ``AttributeError`` on the other + type, so every reader goes through here. A declaration that asserts no refs + returns an empty tuple; one that is not a declaration at all raises + ``TypeError``, because the case worth refusing loudly is the type nobody + meant to pass — a ``getattr`` default would answer "asserts no v1 refs", + which is a wrong answer with no signal rather than a crash. + """ + for name in _V1_REF_SPELLINGS: + if not hasattr(declaration, name): + continue + refs = getattr(declaration, name) + return tuple(coerce_format_id(ref) for ref in refs) if refs else () + raise TypeError( + f"{type(declaration).__name__} declares neither " + f"{' nor '.join(_V1_REF_SPELLINGS)}; it is not a product format declaration" + ) diff --git a/src/adcp/canonical_formats/projection.py b/src/adcp/canonical_formats/projection.py index 3bf9d382e..da5b708cd 100644 --- a/src/adcp/canonical_formats/projection.py +++ b/src/adcp/canonical_formats/projection.py @@ -18,7 +18,11 @@ from pydantic import ValidationError from adcp.canonical_formats.fixtures import load_v1_reference_catalog -from adcp.canonical_formats.identity import canonicalize_agent_url, coerce_format_id +from adcp.canonical_formats.identity import ( + canonicalize_agent_url, + coerce_format_id, + declared_v1_format_refs, +) from adcp.types import Format, Product from adcp.types.legacy import ( FormatReferenceStructuredObject, @@ -783,8 +787,9 @@ def resolve_legacy_format_refs( provide a durable resolver backed by adopter storage or catalog snapshots. """ - if declaration.legacy_format_refs: - return list(declaration.legacy_format_refs) + declared = declared_v1_format_refs(declaration) + if declared: + return list(declared) if resolver is None: raise CanonicalFormatLegacyResolutionError( "canonical creative crossed a process boundary without a durable " @@ -832,7 +837,7 @@ def declaration_fingerprint(declaration: Format) -> tuple[str, tuple[str, ...]]: sort_keys=True, separators=(",", ":"), ) - for ref in declaration.legacy_format_refs + for ref in declared_v1_format_refs(declaration) ) return canonical, legacy diff --git a/src/adcp/canonical_formats/v1_to_v2.py b/src/adcp/canonical_formats/v1_to_v2.py index c8e320515..042e88080 100644 --- a/src/adcp/canonical_formats/v1_to_v2.py +++ b/src/adcp/canonical_formats/v1_to_v2.py @@ -52,6 +52,7 @@ from pydantic import ValidationError from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory +from adcp.canonical_formats.identity import declared_v1_format_refs from adcp.canonical_formats.registry import ( glob_match, load_default_registry, @@ -489,7 +490,7 @@ def group_declarations_by_product( out: dict[str, list[Format]] = {} for declaration in declarations: - refs = declaration.legacy_format_refs + refs = declared_v1_format_refs(declaration) if not refs: continue # A declaration may carry multiple v1 refs (multi-size fan-out diff --git a/src/adcp/canonical_formats/v2_to_v1.py b/src/adcp/canonical_formats/v2_to_v1.py index 5cdd2f6e4..e4cfabfab 100644 --- a/src/adcp/canonical_formats/v2_to_v1.py +++ b/src/adcp/canonical_formats/v2_to_v1.py @@ -40,6 +40,7 @@ from typing import Any from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory +from adcp.canonical_formats.identity import declared_v1_format_refs from adcp.types import ( CanonicalFormatKind, Error, @@ -144,7 +145,7 @@ def project_declaration_to_v1( advisories the resolution order emitted. """ kind = declaration.format_kind - refs = list(declaration.legacy_format_refs) + refs = list(declared_v1_format_refs(declaration)) # Step 1: seller has explicitly opted out of v1 projection. # ``Format`` enforces this is mutually exclusive diff --git a/src/adcp/client.py b/src/adcp/client.py index b6f37abeb..596e55954 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -52,6 +52,7 @@ resolve_creative_dialect, resolve_legacy_format_refs, ) +from adcp.canonical_formats.identity import declared_v1_format_refs from adcp.capabilities import TASK_FEATURE_MAP, FeatureResolver, looks_like_v3_capabilities from adcp.compat.legacy import LEGACY_ADAPTER_VERSIONS from adcp.exceptions import ( @@ -1830,7 +1831,7 @@ def _remember_canonical_product_routes(self, products: list[Any]) -> None: option_id = getattr(declaration, "format_option_id", None) if not option_id: continue - refs = getattr(declaration, "legacy_format_refs", ()) + refs = declared_v1_format_refs(declaration) if not refs: continue route = [ref.model_dump(mode="json") for ref in refs] diff --git a/src/adcp/decisioning/_get_products_helpers.py b/src/adcp/decisioning/_get_products_helpers.py index 1eedf9fcb..63f9e171b 100644 --- a/src/adcp/decisioning/_get_products_helpers.py +++ b/src/adcp/decisioning/_get_products_helpers.py @@ -4,7 +4,7 @@ ``min_length=1``), the framework drops unrequested product fields before returning the response. This module owns that post-processing. -Three categories of ``Product`` fields are **always** passed through +Four categories of ``Product`` fields are **always** passed through regardless of the buyer's selection: 1. **Required-by-schema fields** — ``Product.model_fields`` entries where @@ -13,6 +13,11 @@ ``product_id``, ``name``, ``description``, ``publisher_properties``, ``delivery_type``, ``pricing_options``, ``reporting_capabilities``. +1a. **Fields the schema root's combinator names** — ``format_ids`` and + ``format_options``, which the root requires ONE OF. Neither is required on + its own, so category 1 does not reach them, and dropping both produced a + document the schema refuses. See ``_COMBINATOR_PRODUCT_FIELDS``. + 2. **Non-enum declared fields** — optional ``Product.model_fields`` entries that have no corresponding ``GetProductsField`` enum value (``allowed_actions``, ``cancellation_policy``, ``ext``, @@ -61,6 +66,33 @@ k for k, v in Product.model_fields.items() if v.is_required() ) +#: Fields the schema root names in its own combinator, which the projection must +#: also keep. SEPARATE from the set above, and the distinction is the whole +#: point: those fields cannot be absent, these are INDIVIDUALLY OPTIONAL and +#: COLLECTIVELY REQUIRED. ``core/product.json``'s root states +#: ``sent(format_ids) or sent(format_options)``, so neither is required on its +#: own and dropping both produces a product the schema refuses — +#: ``is_required()`` cannot express that, because it asks about one field at a +#: time. +#: +#: Which is exactly how ten tests came to fail: the projection kept the seven +#: individually-required fields, dropped ``format_options`` as +#: optional-and-unrequested, and the re-validation then failed the root's own +#: constraint. A buyer narrowing ``fields`` got back a document the seller +#: cannot legally serve (salesagent-8qela.86). +#: +#: Folding these INTO ``_REQUIRED_PRODUCT_FIELDS`` was tried and is wrong: that +#: set means "cannot be absent", and +#: ``test_projection_keeps_all_required_fields_even_when_not_requested`` +#: iterates it asserting each is non-None — true of a required field and false +#: of one arm of a combinator. Two meanings, two sets. +#: +#: The generator declares the names on the class, so this asks the model instead +#: of listing them; a hand-written list is what went stale. Direct attribute +#: access on purpose, so a regeneration that stopped emitting it fails at import +#: rather than quietly reinstating the defect. +_COMBINATOR_PRODUCT_FIELDS: frozenset[str] = Product._COMBINATOR_FIELDS + #: Every string value in the GetProductsField enum (the buyer selection #: surface, 30 values). _GET_PRODUCTS_FIELD_VALUES: frozenset[str] = frozenset(f.value for f in GetProductsField) @@ -88,7 +120,9 @@ def _project_product_fields( ``if params.fields:`` enforces this). """ requested = frozenset(f.value for f in fields) - keep_declared = _REQUIRED_PRODUCT_FIELDS | requested | _NON_ENUM_PRODUCT_FIELDS + keep_declared = ( + _REQUIRED_PRODUCT_FIELDS | _COMBINATOR_PRODUCT_FIELDS | requested | _NON_ENUM_PRODUCT_FIELDS + ) if response.products is None: return response diff --git a/src/adcp/decisioning/accounts.py b/src/adcp/decisioning/accounts.py index 9754944fd..072fbde04 100644 --- a/src/adcp/decisioning/accounts.py +++ b/src/adcp/decisioning/accounts.py @@ -624,7 +624,6 @@ def resolve( message=( "ExplicitAccounts.resolve requires ref with 'account_id'; got missing/empty ref" ), - recovery="terminal", field="account.account_id", ) return self._loader(ref["account_id"]) @@ -731,7 +730,6 @@ def resolve( "FromAuthAccounts.resolve requires auth_info with a " "verified principal; got None / empty" ), - recovery="terminal", ) return self._loader(auth_info.principal) diff --git a/src/adcp/decisioning/compose.py b/src/adcp/decisioning/compose.py index 72c5afebd..d8831a5aa 100644 --- a/src/adcp/decisioning/compose.py +++ b/src/adcp/decisioning/compose.py @@ -221,7 +221,6 @@ def _read_field(obj: Any, name: str) -> Any: raise AdcpError( "PERMISSION_DENIED", message=_DENIED_MESSAGE, - recovery="correctable", ) return obj[name] try: @@ -230,7 +229,6 @@ def _read_field(obj: Any, name: str) -> Any: raise AdcpError( "PERMISSION_DENIED", message=_DENIED_MESSAGE, - recovery="correctable", ) from exc @@ -242,7 +240,6 @@ def _read_metadata_field(metadata: Any, name: str) -> Any: raise AdcpError( "PERMISSION_DENIED", message=_DENIED_MESSAGE, - recovery="correctable", ) return _read_field(metadata, name) @@ -276,7 +273,6 @@ async def hook(req: Any, ctx: RequestContext[Any]) -> ShortCircuit[Any] | None: raise AdcpError( "PERMISSION_DENIED", message=_DENIED_MESSAGE, - recovery="correctable", ) return None @@ -308,7 +304,6 @@ async def hook(req: Any, ctx: RequestContext[Any]) -> ShortCircuit[Any] | None: raise AdcpError( "PERMISSION_DENIED", message=_DENIED_MESSAGE, - recovery="correctable", ) return None @@ -341,7 +336,6 @@ async def hook(req: Any, ctx: RequestContext[Any]) -> ShortCircuit[Any] | None: raise AdcpError( "PERMISSION_DENIED", message=_DENIED_MESSAGE, - recovery="correctable", ) return None diff --git a/src/adcp/decisioning/derive_packages.py b/src/adcp/decisioning/derive_packages.py index 570ec80a7..09bd55598 100644 --- a/src/adcp/decisioning/derive_packages.py +++ b/src/adcp/decisioning/derive_packages.py @@ -109,7 +109,6 @@ def derive_packages_from_proposal( "budgets by applying the proposal's allocation_percentage " "values to total_budget.amount." ), - recovery="correctable", field="total_budget", ) @@ -118,7 +117,6 @@ def derive_packages_from_proposal( raise AdcpError( "INVALID_REQUEST", message="total_budget.amount is required for package derivation.", - recovery="correctable", field="total_budget.amount", ) diff --git a/src/adcp/decisioning/discovery_guards.py b/src/adcp/decisioning/discovery_guards.py index 7440b714b..fe126d29b 100644 --- a/src/adcp/decisioning/discovery_guards.py +++ b/src/adcp/decisioning/discovery_guards.py @@ -106,7 +106,6 @@ def assert_discovery_push_consistent(req: Any, *, mode_field: str) -> None: "which supports the async (submitted) arm." ), field="push_notification_config", - recovery="correctable", ) @@ -124,7 +123,6 @@ def _wholesale_handoff_error(mode_field: str) -> AdcpError: "background task. The 'brief' mode is the async-capable path." ), field=mode_field, - recovery="correctable", ) @@ -218,7 +216,6 @@ def assert_account_resolved_for_async( "synchronous discovery call." ), field="account", - recovery="correctable", ) @@ -267,7 +264,6 @@ def reject_hand_rolled_submitted(result: Any, *, operation: str = "Discovery") - "the task_id, persists the submitted state, and emits the wire " "envelope for you." ), - recovery="correctable", ) diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 61b33faef..c525b0907 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -781,7 +781,9 @@ def _internal_error_details(exc: BaseException) -> dict[str, Any]: # --------------------------------------------------------------------------- -def _validation_error_to_invalid_request(method_name: str, exc: ValidationError) -> AdcpError: +def _validation_error_to_invalid_request( + method_name: str, exc: ValidationError, payload: Any +) -> AdcpError: """Convert a ``pydantic.ValidationError`` raised inside a platform method to ``AdcpError('INVALID_REQUEST', recovery='correctable')``. @@ -799,11 +801,25 @@ def _validation_error_to_invalid_request(method_name: str, exc: ValidationError) genuine bug worth surfacing rather than masking with a fallback. """ from adcp.types.error_narrowing import narrow_union_errors + from adcp.validation.pydantic_errors import _document_path raw = exc.errors(include_input=False, include_context=False, include_url=False) errors: list[Any] = list(narrow_union_errors(raw)) first: dict[str, Any] = dict(errors[0]) if errors else {} - field_path = ".".join(str(loc) for loc in first.get("loc", ())) + # `field` is a path into the buyer's DOCUMENT, so the loc is resolved + # against it rather than joined raw. pydantic's loc carries elements that + # address nothing the peer sent — a wrapped member's core-schema + # expression, and a union branch's class name for any union at all — and + # joining them produced e.g. "choice.FirstVariant.value" for a document + # whose only keys are choice and value. + # + # `payload` is read for KEY MEMBERSHIP only: the resolver appends elements + # of the loc and never anything out of the document, so no buyer value + # reaches this error. That is what keeps the field value-free while still + # letting it be resolvable. + field_path = ".".join( + str(part) for part in _document_path(tuple(first.get("loc", ())), payload) + ) msg = first.get("msg", "validation failed") return AdcpError( "INVALID_REQUEST", @@ -812,7 +828,6 @@ def _validation_error_to_invalid_request(method_name: str, exc: ValidationError) + (f" (field: {field_path!r})" if field_path else "") ), field=field_path or None, - recovery="correctable", details={"validation_errors": errors}, ) @@ -1431,7 +1446,6 @@ def _coerce_params_to_platform_type( + (f" (field: {field_path!r})" if field_path else "") ), field=field_path or None, - recovery="correctable", ) from exc break @@ -1729,7 +1743,7 @@ async def _invoke_platform_method( method_name, exc_info=True, ) - wrapped = _validation_error_to_invalid_request(method_name, exc) + wrapped = _validation_error_to_invalid_request(method_name, exc, params) if on_failure is not None: await _safe_on_failure_call(on_failure, wrapped, method_name) raise wrapped from exc @@ -1846,7 +1860,6 @@ async def _project_invocation_result( f"{method_name} cannot use WorkflowHandoff because external registry " "completion would bypass framework response verification; use TaskHandoff" ), - recovery="correctable", ) if on_failure is not None: await _safe_on_failure_call(on_failure, rejection, method_name) @@ -1901,7 +1914,6 @@ async def _project_invocation_result( "instead so the framework issues the task, enforces push " "ownership, and persists a pollable lifecycle." ), - recovery="correctable", ) if on_failure is not None: await _safe_on_failure_call(on_failure, rejection, method_name) @@ -2075,7 +2087,6 @@ async def _validate_handoff_push_ownership( "push_notification_config.operation_id is required before " "this request can enter an asynchronous task lifecycle" ), - recovery="correctable", field="push_notification_config.operation_id", suggestion=( "Supply a stable client-generated operation_id, or omit " @@ -2089,7 +2100,6 @@ async def _validate_handoff_push_ownership( "This push-configured task has no conformant durable webhook " "publisher under the AdCP 3.2 beta.5 contract" ), - recovery="correctable", field="push_notification_config", suggestion=( "Omit push_notification_config and poll tasks/get, or configure " diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 213945a61..7079e034a 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -728,7 +728,6 @@ async def _resolve_buyer_agent( raise AdcpError( "PERMISSION_DENIED", message=_denied_message, - recovery="correctable", ) if agent.status == "active": @@ -742,13 +741,11 @@ async def _resolve_buyer_agent( raise AdcpError( "AGENT_SUSPENDED", message=_denied_message, - recovery="terminal", ) if agent.status == "blocked": raise AdcpError( "AGENT_BLOCKED", message=_denied_message, - recovery="terminal", ) # Default-reject any non-active status the framework doesn't # recognize (typo, future enum value, adopter-custom string). A @@ -763,7 +760,6 @@ async def _resolve_buyer_agent( raise AdcpError( "PERMISSION_DENIED", message=_denied_message, - recovery="correctable", ) @@ -864,7 +860,6 @@ async def _enforce_brand_authorization( raise AdcpError( "PERMISSION_DENIED", message=_denied_message, - recovery="correctable", ) @@ -1669,7 +1664,6 @@ async def _resolve_account( raise AdcpError( "ACCOUNT_REQUIRED", message="An account is required for product discovery", - recovery="correctable", field="account", ) if ref is None and tool_name in { @@ -1684,7 +1678,6 @@ async def _resolve_account( raise AdcpError( "ACCOUNT_NOT_FOUND", message="Account reference could not be resolved", - recovery="terminal", field="account", ) # Phase 1 sandbox-authority — track explicit mode values for the @@ -2356,7 +2349,6 @@ async def refine_proposals( # type: ignore[override] raise AdcpError( "INVALID_STATE", message="finalize requires a draft source proposal", - recovery="correctable", field="refinements.proposal_id", details={"proposal_id": proposal_id}, ) @@ -2756,7 +2748,6 @@ async def create_media_buy( # type: ignore[override] f"{len(product_ids)} product(s). Retry the request; " "if the problem persists contact the seller." ), - recovery="transient", details=_exception_cause_details(exc), ) from exc @@ -3336,7 +3327,6 @@ def _reject_unrequested_async_preview() -> None: "preview_creative may return a task handoff only when " "the request explicitly sets allow_async=true" ), - recovery="correctable", field="allow_async", ) @@ -3617,7 +3607,6 @@ async def check_governance( # type: ignore[override] raise AdcpError( "INVALID_REQUEST", message=str(exc), - recovery="correctable", ) from exc account = await self._resolve_account(None, tool_ctx, tool_name="check_governance") ctx = self._build_ctx(tool_ctx, account) @@ -3688,7 +3677,6 @@ async def report_plan_outcome( # type: ignore[override] raise AdcpError( "INVALID_REQUEST", message=str(exc), - recovery="correctable", ) from exc account = await self._resolve_account(None, tool_ctx, tool_name="report_plan_outcome") ctx = self._build_ctx(tool_ctx, account) diff --git a/src/adcp/decisioning/negotiation.py b/src/adcp/decisioning/negotiation.py index 727e07484..98bde1f04 100644 --- a/src/adcp/decisioning/negotiation.py +++ b/src/adcp/decisioning/negotiation.py @@ -79,7 +79,6 @@ def _wire_refinements(request: BaseModel | Mapping[str, Any]) -> list[dict[str, raise AdcpError( "VALIDATION_ERROR", message="refinements must be an ordered non-empty array", - recovery="correctable", field="refinements", ) return refinements @@ -114,7 +113,6 @@ def validate_finalize_source_states_or_raise( raise AdcpError( "INVALID_STATE", message="source proposal payload does not match the requested proposal_id", - recovery="correctable", field=f"refinements.{index}.proposal_id", details={"proposal_id": source_id}, ) @@ -122,7 +120,6 @@ def validate_finalize_source_states_or_raise( raise AdcpError( "INVALID_STATE", message="finalize requires a draft source proposal", - recovery="correctable", field=f"refinements.{index}.proposal_id", details={"proposal_id": source_id}, ) @@ -156,7 +153,6 @@ def preflight_refinement_batch_or_raise( raise AdcpError( "VALIDATION_ERROR", message="proposal refinement request is schema-invalid", - recovery="correctable", field="refinements", details={"schema_errors": exc.errors(include_url=False)}, ) from exc @@ -174,7 +170,6 @@ def preflight_refinement_batch_or_raise( raise AdcpError( "UNSUPPORTED_FEATURE", message=f"refinement dimension {dimension!r} is not supported", - recovery="correctable", field=unsupported.pointer, details={ "unsupported_dimension": dimension, @@ -185,7 +180,6 @@ def preflight_refinement_batch_or_raise( raise AdcpError( "VALIDATION_ERROR", message="proposal refinement batch failed preflight", - recovery="correctable", field=first.pointer, details=_issues_details(result.issues), ) diff --git a/src/adcp/decisioning/pagination.py b/src/adcp/decisioning/pagination.py index 222159c27..01c92be4b 100644 --- a/src/adcp/decisioning/pagination.py +++ b/src/adcp/decisioning/pagination.py @@ -66,9 +66,7 @@ def _encode_cursor(offset: int, query_hash: str, secret: bytes | None = None) -> {"v": _CURSOR_VERSION, "o": offset, "qh": query_hash}, separators=(",", ":") ).encode() sig = _sign(payload, secret if secret is not None else _secret()) - envelope = json.dumps( - {"p": payload.decode(), "s": sig}, separators=(",", ":") - ).encode() + envelope = json.dumps({"p": payload.decode(), "s": sig}, separators=(",", ":")).encode() return base64.urlsafe_b64encode(envelope).rstrip(b"=").decode() @@ -86,7 +84,6 @@ def _decode_cursor(cursor: str, expected_query_hash: str, secret: bytes | None = "pagination.cursor is malformed or expired. Omit the cursor to restart from page 1." ), field="pagination.cursor", - recovery="correctable", ) try: # Restore stripped padding before decoding. @@ -118,7 +115,6 @@ def _decode_cursor(cursor: str, expected_query_hash: str, secret: bytes | None = "issued. Omit the cursor to restart pagination with the new filter values." ), field="pagination.cursor", - recovery="correctable", suggestion="Omit the cursor to start from the first page with the updated filters.", ) @@ -200,9 +196,7 @@ def apply_framework_pagination( full_list = list(products) page = full_list[offset : offset + max_results] has_more = (offset + max_results) < len(full_list) - next_cursor = ( - _encode_cursor(offset + max_results, query_hash, secret) if has_more else None - ) + next_cursor = _encode_cursor(offset + max_results, query_hash, secret) if has_more else None # Deferred: adcp.types imports adcp.decisioning.types; top-level import is circular. from adcp.types import PaginationResponse diff --git a/src/adcp/decisioning/pg/proposal_store.py b/src/adcp/decisioning/pg/proposal_store.py index 9b8d021bb..c182b0082 100644 --- a/src/adcp/decisioning/pg/proposal_store.py +++ b/src/adcp/decisioning/pg/proposal_store.py @@ -675,7 +675,6 @@ async def try_reserve_consumption( raise AdcpError( "PROPOSAL_NOT_FOUND", message=(f"Proposal {proposal_id!r} not found."), - recovery="correctable", field="proposal_id", ) ( @@ -695,7 +694,6 @@ async def try_reserve_consumption( "committed proposal that hasn't been accepted " "or reserved by another request." ), - recovery="correctable", field="proposal_id", ) await conn.execute( diff --git a/src/adcp/decisioning/platform.py b/src/adcp/decisioning/platform.py index 3c67171e1..1c4e00f3a 100644 --- a/src/adcp/decisioning/platform.py +++ b/src/adcp/decisioning/platform.py @@ -528,7 +528,6 @@ def upstream_for( "AccountStore.resolve for mock-mode accounts. " "See docs/handler-authoring.md#mock-mode-upstream-routing." ), - recovery="terminal", field="account.metadata.mock_upstream_url", ) else: @@ -547,7 +546,6 @@ def upstream_for( "or mark the account mode='mock' and populate " "metadata['mock_upstream_url']." ), - recovery="terminal", ) base_url = self.upstream_url diff --git a/src/adcp/decisioning/platform_router.py b/src/adcp/decisioning/platform_router.py index f650a9c7d..1257f13b3 100644 --- a/src/adcp/decisioning/platform_router.py +++ b/src/adcp/decisioning/platform_router.py @@ -308,7 +308,6 @@ def _tenant_id_from_ctx(ctx: RequestContext[Any]) -> str: "resolved account; got " f"{tenant_id!r}." ), - recovery="terminal", field="account.metadata.tenant_id", ) return str(tenant_id) @@ -509,7 +508,6 @@ def _platform_for( "``platforms=`` mapping passed to the router, or " "fix the AccountStore to resolve to a known tenant." ), - recovery="terminal", field="account.metadata.tenant_id", ) @@ -1038,7 +1036,6 @@ async def _resolve_platform(self, tenant_id: str) -> DecisioningPlatform: "AdcpError from inside the factory if the tenant should " "be rejected." ), - recovery="terminal", field="account.metadata.tenant_id", ) @@ -1333,7 +1330,6 @@ async def _resolve_tenant_platform( "before dispatch. Check your SubdomainTenantMiddleware or " "context_factory wiring." ), - recovery="terminal", field="ctx.tenant_id", ) resolution = await self._registry.resolve_by_id(tenant_id) @@ -1353,7 +1349,6 @@ async def _resolve_tenant_platform( f"RegistryPlatformAdapter: tenant {tenant_id!r} is not available " f"(health={health!r}). Allowed states: {sorted(self._serve_states)}." ), - recovery="transient", details={"tenant_id": tenant_id, "health": health}, ) # Guard catches tenants whose resolved platform doesn't implement the diff --git a/src/adcp/decisioning/property_list.py b/src/adcp/decisioning/property_list.py index 44ec9b71a..6a350531c 100644 --- a/src/adcp/decisioning/property_list.py +++ b/src/adcp/decisioning/property_list.py @@ -117,7 +117,6 @@ async def resolve_property_list( raise AdcpError( "INVALID_REQUEST", message="Property list_id must be a single URL-safe path segment", - recovery="correctable", details={"list_id": list_id}, ) @@ -131,7 +130,6 @@ async def resolve_property_list( raise AdcpError( "INVALID_REQUEST", message="Property list agent_url failed destination policy", - recovery="correctable", details={"reason": exc.reason}, ) from None @@ -158,7 +156,6 @@ async def resolve_property_list( f"Property list fetch failed for list_id={list_id!r} " f"from agent_origin={safe_origin!r}" ), - recovery="transient", details={"list_id": list_id, "agent_origin": safe_origin}, ) from None diff --git a/src/adcp/decisioning/proposal_dispatch.py b/src/adcp/decisioning/proposal_dispatch.py index b7e34a5a8..bfd48e9bd 100644 --- a/src/adcp/decisioning/proposal_dispatch.py +++ b/src/adcp/decisioning/proposal_dispatch.py @@ -255,7 +255,6 @@ async def maybe_intercept_finalize( "get_products with buying_mode='brief' or 'refine' to " "obtain a draft proposal_id before finalizing it." ), - recovery="correctable", field=field_path, ) # Finalize requires a draft; finalizing an already-committed proposal @@ -272,7 +271,6 @@ async def maybe_intercept_finalize( "finalized. Already-committed proposals should be " "accepted via create_media_buy(proposal_id=...) directly." ), - recovery="correctable", field=field_path, ) @@ -445,7 +443,6 @@ async def maybe_validate_refine_proposal_refs( "get_products with buying_mode='brief' to obtain a draft " "proposal_id before refining it." ), - recovery="correctable", field=f"refine[{refine_index}].proposal_id", ) diff --git a/src/adcp/decisioning/proposal_lifecycle.py b/src/adcp/decisioning/proposal_lifecycle.py index 7dfe44810..e65f16b98 100644 --- a/src/adcp/decisioning/proposal_lifecycle.py +++ b/src/adcp/decisioning/proposal_lifecycle.py @@ -111,7 +111,6 @@ async def enforce_proposal_expiry( "refine=[{action:'finalize',...}] to obtain a committed " "proposal_id before referencing it on create_media_buy." ), - recovery="correctable", field="proposal_id", ) if record.state != ProposalState.COMMITTED: @@ -123,7 +122,6 @@ async def enforce_proposal_expiry( "be accepted via create_media_buy. Call get_products " "with buying_mode='refine' and action='finalize' first." ), - recovery="correctable", field="proposal_id", ) if record.expires_at is not None: diff --git a/src/adcp/decisioning/proposal_manager.py b/src/adcp/decisioning/proposal_manager.py index b5730f61b..3439dd7f6 100644 --- a/src/adcp/decisioning/proposal_manager.py +++ b/src/adcp/decisioning/proposal_manager.py @@ -633,7 +633,6 @@ def __init__( "``mock_upstream_url`` pointing at a running " "``bin/adcp.js mock-server <specialism>`` instance." ), - recovery="terminal", field="mock_upstream_url", ) @@ -698,7 +697,6 @@ async def get_products( f"a non-dict payload from /get_products: " f"{type(result).__name__}" ), - recovery="transient", ) return result diff --git a/src/adcp/decisioning/proposal_store.py b/src/adcp/decisioning/proposal_store.py index 89b791d73..99c52fe80 100644 --- a/src/adcp/decisioning/proposal_store.py +++ b/src/adcp/decisioning/proposal_store.py @@ -556,7 +556,6 @@ async def try_reserve_consumption( raise AdcpError( "PROPOSAL_NOT_FOUND", message=(f"Proposal {proposal_id!r} not found."), - recovery="correctable", field="proposal_id", ) if record.state != ProposalState.COMMITTED: @@ -568,7 +567,6 @@ async def try_reserve_consumption( "requires a committed proposal that hasn't " "been accepted or reserved by another request." ), - recovery="correctable", field="proposal_id", ) reserved = replace(record, state=ProposalState.CONSUMING) diff --git a/src/adcp/decisioning/refine.py b/src/adcp/decisioning/refine.py index 587fdbc54..20ce05dcd 100644 --- a/src/adcp/decisioning/refine.py +++ b/src/adcp/decisioning/refine.py @@ -81,11 +81,20 @@ class RefineResult: :param per_refine_outcome: Exactly ``len(request.refine)`` entries, in the same order. Mismatched length is a developer-facing error (raised by the framework before the response is built). + :param cache_scope: Which cache layer this response's product set + describes -- ``'public'`` for the seller's published rates, + ``'account'`` for an account-specific overlay. Required on every 3.1+ + ``get_products`` response by the root conditional's standard branch, + and the two-layer cache model's safety property depends on it, so the + framework cannot omit it. Defaulted to ``'public'`` because a refine + that carries no account-specific versions describes the published + layer; an adopter serving an overlay says so here. """ products: list[Any] proposals: list[Any] | None per_refine_outcome: list[RefinementOutcome] = field(default_factory=list) + cache_scope: Literal["public", "account"] = "public" def assert_buying_mode_consistent(req: GetProductsRequest) -> None: @@ -232,6 +241,7 @@ def project_refine_response( (developer-facing — adopter contract violation). """ from adcp.types import GetProductsResponse + from adcp.types.domains.media_buy.get_products_response import CacheScope refinement_applied = build_refinement_applied(refines, result.per_refine_outcome) @@ -239,6 +249,7 @@ def project_refine_response( products=list(result.products), proposals=list(result.proposals) if result.proposals is not None else None, refinement_applied=refinement_applied, + cache_scope=CacheScope(result.cache_scope), ) diff --git a/src/adcp/decisioning/registry.py b/src/adcp/decisioning/registry.py index ec6e664eb..eeebf814a 100644 --- a/src/adcp/decisioning/registry.py +++ b/src/adcp/decisioning/registry.py @@ -374,7 +374,6 @@ def validate_billing_for_agent( "durable store." ), field="billing", - recovery="correctable", details=details, ) diff --git a/src/adcp/decisioning/registry_cache.py b/src/adcp/decisioning/registry_cache.py index 1f95f2ec4..bc172355a 100644 --- a/src/adcp/decisioning/registry_cache.py +++ b/src/adcp/decisioning/registry_cache.py @@ -131,7 +131,6 @@ def _denied_error() -> AdcpError: return AdcpError( "PERMISSION_DENIED", message=_DENIED_MESSAGE, - recovery="correctable", ) diff --git a/src/adcp/decisioning/resolve.py b/src/adcp/decisioning/resolve.py index 056494e67..5c35072b4 100644 --- a/src/adcp/decisioning/resolve.py +++ b/src/adcp/decisioning/resolve.py @@ -27,7 +27,7 @@ # Wire types — already exported from adcp.types. Re-export for # one-stop import from adcp.decisioning. Per CLAUDE.md import -# architecture rules, only adcp.types/{stable,aliases,_ergonomic} may +# architecture rules, only adcp.types/{stable,aliases} may # import from the generated tree; everywhere else uses the public # adcp.types surface. from adcp.types import ( diff --git a/src/adcp/decisioning/state.py b/src/adcp/decisioning/state.py index c32b0957e..d670c90e2 100644 --- a/src/adcp/decisioning/state.py +++ b/src/adcp/decisioning/state.py @@ -35,7 +35,7 @@ # Wire types referenced through the StateReader. ``Proposal`` is # exported from adcp.types; importing from there keeps the layering -# rule in CLAUDE.md happy (only adcp.types/{stable,aliases,_ergonomic} +# rule in CLAUDE.md happy (only adcp.types/{stable,aliases} # may import from the generated tree). from adcp.types import Proposal diff --git a/src/adcp/decisioning/state_machines.py b/src/adcp/decisioning/state_machines.py index e066f5da1..c754a3e8f 100644 --- a/src/adcp/decisioning/state_machines.py +++ b/src/adcp/decisioning/state_machines.py @@ -151,7 +151,6 @@ def _assert_transition( f"Unknown {resource_kind} state {from_state!r}; " f"expected one of {sorted(graph.keys())}." ), - recovery="correctable", field="status", details=_details(id_field, resource_id, from_state, to_state), ) @@ -164,7 +163,6 @@ def _assert_transition( f"{resource_kind.capitalize()} is in terminal state " f"{from_state!r}; transition to {to_state!r} is not permitted." ), - recovery="correctable", field="status", details=_details(id_field, resource_id, from_state, to_state), ) @@ -176,7 +174,6 @@ def _assert_transition( f"Illegal {resource_kind} transition {from_state!r} → " f"{to_state!r}; legal next states: {sorted(legal)}." ), - recovery="correctable", field="status", details=_details(id_field, resource_id, from_state, to_state), ) diff --git a/src/adcp/decisioning/time_budget.py b/src/adcp/decisioning/time_budget.py index 9fe4a1d07..60617fdf0 100644 --- a/src/adcp/decisioning/time_budget.py +++ b/src/adcp/decisioning/time_budget.py @@ -266,6 +266,15 @@ def project_incomplete_response(*, interval: int, unit: str) -> dict[str, Any]: ) return { "products": [], + # ``cache_scope`` is required on every 3.1+ get_products response: the + # root conditional's standard branch names it beside ``products``, and + # the two-layer cache model's safety property depends on it. A timeout + # response carries no ``wholesale_feed_version`` or ``pricing_version``, + # so it describes no account-specific overlay, and ``public`` is what + # the published layer is called. Omitted here until the generated model + # began enforcing the conditional, which made the SDK's own timeout + # response one its schema refuses. + "cache_scope": "public", "incomplete": [ { "scope": "products", diff --git a/src/adcp/decisioning/trust_grants.py b/src/adcp/decisioning/trust_grants.py index 36a69f7c3..292c9398e 100644 --- a/src/adcp/decisioning/trust_grants.py +++ b/src/adcp/decisioning/trust_grants.py @@ -140,9 +140,7 @@ async def require_claim( return operator = registrable_domain(agent.agent_url) if operator is None: - raise AdcpError( - "PERMISSION_DENIED", message="Buyer agent is not authorized", recovery="correctable" - ) + raise AdcpError("PERMISSION_DENIED", message="Buyer agent is not authorized") key: _ClaimKey = ( canonical_origin(agent.agent_url), registrable_domain(claim.brand_domain), @@ -161,7 +159,6 @@ async def require_claim( raise AdcpError( "SERVICE_UNAVAILABLE", message="Claim history admission capacity exceeded", - recovery="transient", ) self._history.claims[key] = 0 self._history.operator_counts[operator] = ( @@ -198,9 +195,7 @@ async def _require_admitted_claim( ) -> None: if agent is None: # No established commercial identity: omit details under the oracle clamp. - raise AdcpError( - "PERMISSION_DENIED", message="Buyer agent is not authorized", recovery="correctable" - ) + raise AdcpError("PERMISSION_DENIED", message="Buyer agent is not authorized") key: _ClaimKey = ( canonical_origin(agent.agent_url), registrable_domain(claim.brand_domain), @@ -314,7 +309,6 @@ async def require_account( "PERMISSION_DENIED", message="Operation requires an explicit grant scope", details={"scope": "agent", "reason": "grant_absent"}, - recovery="correctable", ) await self.require_claim( claim, @@ -346,7 +340,6 @@ async def require_declaration( "PERMISSION_DENIED", message="Account declaration has no grant authority", details={"scope": "agent", "reason": "grant_absent"}, - recovery="correctable", ) await self.require_claim( TrustGrantClaim( diff --git a/src/adcp/decisioning/types.py b/src/adcp/decisioning/types.py index 3eee00efd..fc893eea3 100644 --- a/src/adcp/decisioning/types.py +++ b/src/adcp/decisioning/types.py @@ -23,7 +23,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Generic, Literal +from typing import TYPE_CHECKING, Any, Generic, Literal, cast # PEP 696 TypeVar defaults + PEP 695 / PEP 718 generic TypeAlias both # need ``typing_extensions`` backports for Python 3.10-3.12 (the package @@ -59,6 +59,40 @@ ) +def _registered_recovery(code: str) -> Literal["correctable", "transient", "terminal"]: + """The recovery ``enums/error-code.json`` registers for ``code``. + + Asked of :data:`~adcp.server.helpers.STANDARD_ERROR_CODES`, which is built + from the pinned schema's ``enumMetadata`` block and fails closed on a code + the block does not classify. That block's own ``$comment`` is addressed at + SDKs — "SDKs MUST consume this block instead of parsing 'Recovery: X' from + enumDescriptions prose" — so this is the one place the answer comes from and + there is no second table to drift from it. + + An unregistered code answers ``terminal``, matching + :func:`~adcp.server.helpers.adcp_error` exactly: both resolve the same + vocabulary, so a code must not classify differently depending on which of + the two an adopter reached for. ``INTERNAL_ERROR`` is the case that matters — + it is in neither the spec enum nor the SDK extensions and is raised at 55 + sites, and ``terminal`` is both its previous value and the right one. + + This differs on purpose from :func:`adcp.signing.recovery_for`, which RAISES + for an unclassified code on the grounds that a default would answer for a + code no spec block speaks for. That taxonomy has no unregistered-code legacy + to serve; raising here would convert 55 working raise sites into errors + rather than classify them. + """ + # Imported inside the function: `adcp.server.helpers` reads the pinned + # schema from disk at import time, and `adcp.decisioning.types` is imported + # for its TypedDicts by modules that must not pay for that. + from adcp.server.helpers import STANDARD_ERROR_CODES + + info = STANDARD_ERROR_CODES.get(code) + if info is None: + return "terminal" + return cast(Literal["correctable", "transient", "terminal"], info["recovery"]) + + class AdcpError(Exception): """Wire-shaped structured error raised by platform methods. @@ -91,7 +125,13 @@ class AdcpError(Exception): outside the enum are accepted (``str``) but buyers won't have first-class handling for them. :param message: Human-readable error message. Always set. - :param recovery: Buyer's retry strategy: + :param recovery: Buyer's retry strategy. **Omit it** and the + classification ``enums/error-code.json`` registers for ``code`` is + used, via :func:`_registered_recovery`; an unregistered code answers + ``'terminal'``. Pass one only to OVERRIDE the registered + classification for your own deployment — inside this package that + override is a second representation of a fact the schema already + owns, and ``tests/test_error_recovery_table.py`` refuses it. * ``'retry_with_changes'`` — fix the indicated field and retry * ``'correctable'`` — same as retry_with_changes (legacy alias) @@ -114,9 +154,9 @@ def __init__( code: str, *, message: str = "", - recovery: Literal[ - "retry_with_changes", "correctable", "transient", "terminal" - ] = "terminal", + recovery: ( + Literal["retry_with_changes", "correctable", "transient", "terminal"] | None + ) = None, field: str | None = None, suggestion: str | None = None, retry_after: int | None = None, @@ -124,7 +164,7 @@ def __init__( ) -> None: super().__init__(message or code) self.code = code - self.recovery = recovery + self.recovery = recovery if recovery is not None else _registered_recovery(code) self.field = field self.suggestion = suggestion self.retry_after = retry_after diff --git a/src/adcp/decisioning/upstream.py b/src/adcp/decisioning/upstream.py index 9a1b1ecf4..bff6356ba 100644 --- a/src/adcp/decisioning/upstream.py +++ b/src/adcp/decisioning/upstream.py @@ -138,7 +138,6 @@ def _project_status( return AdcpError( "INVALID_REQUEST", message=base, - recovery="correctable", ) @@ -282,7 +281,6 @@ async def _request( f"upstream {method} {path} returned non-JSON body " f"(status {response.status_code}): {exc}" ), - recovery="transient", ) from exc return parsed diff --git a/src/adcp/governance.py b/src/adcp/governance.py index ba38f9b56..cdb6e87ff 100644 --- a/src/adcp/governance.py +++ b/src/adcp/governance.py @@ -43,6 +43,7 @@ from adcp.signing.jwks import AsyncCachingJwksResolver, AsyncJwksResolver, JwksResolver from adcp.signing.replay import ReplayStore, supports_atomic_claim from adcp.types import CheckGovernanceRequest, ReportPlanOutcomeRequest +from adcp.validation.schema_validator import SchemaRuleError, rule GOVERNANCE_AUTHORIZATION_TYP = "adcp-gov+jws" GOVERNANCE_AUTHORIZATION_CRITICAL_CLAIMS = ( @@ -619,7 +620,7 @@ def validate_governance_request( """ raw = _record(request, exclude_unset=True) if raw is None: - raise ValueError("invalid check_governance request") + raise SchemaRuleError(rule("type", "/", "expected type 'object'")) intent_keys = { "target_agent", "tool", @@ -634,24 +635,73 @@ def validate_governance_request( has_intent = any(key in raw for key in intent_keys) has_execution = any(key in raw for key in execution_keys) if has_intent and has_execution: - raise ValueError("check_governance request cannot mix intent and execution fields") + raise SchemaRuleError( + rule( + "oneOf", + "/", + "check_governance request cannot mix intent and execution fields", + ) + ) if has_intent: missing = [ key for key in ("plan_id", "tool", "payload", "target_agent") if raw.get(key) is None ] if missing or "governance_context" in raw: - raise ValueError("intent check requires plan_id, tool, payload, and target_agent only") + # TWO rules at one site: the four keys an intent check requires, and + # the one it forbids. One row each, so neither class is lost. + raise SchemaRuleError( + *( + rule("required", f"/{key}", f"'{key}' is a required property") + for key in missing + ), + *( + [ + rule( + "not", + "/governance_context", + "'governance_context' is not accepted by an intent check", + ) + ] + if "governance_context" in raw + else [] + ), + ) tool = raw.get("tool") if governance_task_requires_proposed_commitment(cast(str, tool)) and ( "proposed_commitment" not in raw ): - raise ValueError(f"{tool} intent check requires proposed_commitment") + raise SchemaRuleError( + rule( + "dependencies", + "/proposed_commitment", + f"'proposed_commitment' is required for a {tool} intent check", + ) + ) if tool == "accept_proposal" and "proposal" not in raw: - raise ValueError("accept_proposal intent check requires proposal") + raise SchemaRuleError( + rule( + "dependencies", + "/proposal", + "'proposal' is required for an accept_proposal intent check", + ) + ) if "proposal" in raw and tool != "accept_proposal": - raise ValueError("proposal is valid only for accept_proposal intent checks") + raise SchemaRuleError( + rule( + "dependencies", + "/proposal", + "'proposal' is accepted only for an accept_proposal intent check", + ) + ) if "consultation_context" in raw and "governance_context" in raw: - raise ValueError("consultation context is non-authorizing") + raise SchemaRuleError( + rule( + "not", + "/consultation_context", + "'consultation_context' is non-authorizing and cannot accompany" + " 'governance_context'", + ) + ) if "runtime_attestations" in raw: payload = raw.get("payload") if ( @@ -660,33 +710,82 @@ def validate_governance_request( or not isinstance(payload, Mapping) or payload.get("action", "activate") != "activate" ): - raise ValueError( - "runtime_attestations require an activate_signal activation intent" + raise SchemaRuleError( + rule( + "dependencies", + "/runtime_attestations", + "'runtime_attestations' is accepted only on an activate_signal" + " activation intent", + ) ) return "intent" if has_execution: if raw.get("governance_context") is None or any(key in raw for key in intent_keys): - raise ValueError("execution check requires governance_context and no intent payload") + # TWO rules at one site, as above: the key an execution check requires + # and the intent keys it forbids. + raise SchemaRuleError( + *( + [ + rule( + "required", + "/governance_context", + "'governance_context' is a required property", + ) + ] + if raw.get("governance_context") is None + else [] + ), + *( + rule("not", f"/{key}", f"'{key}' is not accepted by an execution check") + for key in sorted(intent_keys & raw.keys()) + ), + ) if "planned_delivery" not in raw: - raise ValueError("execution check requires planned_delivery") + raise SchemaRuleError( + rule("required", "/planned_delivery", "'planned_delivery' is a required property") + ) phase = raw.get("phase", "purchase") delivery = raw.get("planned_delivery") if isinstance(delivery, BaseModel): delivery = delivery.model_dump(mode="json", exclude_unset=True) if not isinstance(delivery, Mapping): - raise ValueError("execution check requires planned_delivery") + raise SchemaRuleError(rule("type", "/planned_delivery", "expected type 'object'")) if phase in {"modification", "delivery"} and (not delivery.get("media_buy_id")): - raise ValueError(f"{phase} execution check requires planned_delivery.media_buy_id") + raise SchemaRuleError( + rule( + "dependencies", + "/planned_delivery/media_buy_id", + f"'media_buy_id' is required for a {phase} execution check", + ) + ) if delivery.get("total_budget") is not None and delivery.get("currency") is None: - raise ValueError("planned_delivery.total_budget requires currency") + raise SchemaRuleError( + rule( + "dependencies", + "/planned_delivery/currency", + "'currency' is required when 'total_budget' is present", + ) + ) if phase == "modification" and "execution_commitment" not in raw: - raise ValueError("modification execution check requires execution_commitment") + raise SchemaRuleError( + rule( + "dependencies", + "/execution_commitment", + "'execution_commitment' is required for a modification execution check", + ) + ) if phase == "delivery" and "delivery_metrics" not in raw: - raise ValueError("delivery execution check requires delivery_metrics") + raise SchemaRuleError( + rule( + "dependencies", + "/delivery_metrics", + "'delivery_metrics' is required for a delivery execution check", + ) + ) if phase == "delivery": metrics = raw.get("delivery_metrics") if not isinstance(metrics, Mapping): - raise ValueError("delivery execution check requires delivery_metrics") + raise SchemaRuleError(rule("type", "/delivery_metrics", "expected type 'object'")) required_metrics = { "statement_id", "statement_digest", @@ -697,9 +796,21 @@ def validate_governance_request( "currency", } if any(metrics.get(key) is None for key in required_metrics): - raise ValueError("delivery_metrics is missing its canonical statement binding") + raise SchemaRuleError( + *( + rule( + "required", + f"/delivery_metrics/{key}", + f"'{key}' is a required property of the canonical statement binding", + ) + for key in sorted(required_metrics) + if metrics.get(key) is None + ) + ) return "execution" - raise ValueError("check_governance request must be an intent or execution check") + raise SchemaRuleError( + rule("anyOf", "/", "check_governance request must be an intent or execution check") + ) def validate_governance_outcome_request( @@ -711,17 +822,19 @@ def validate_governance_outcome_request( """Enforce outcome/body/check binding before governance adopter code.""" raw = _record(request, exclude_unset=True) if raw is None: - raise ValueError("invalid report_plan_outcome request") + raise SchemaRuleError(rule("type", "/", "expected type 'object'")) outcome = raw.get("outcome") if not isinstance(outcome, str): - raise ValueError("invalid governance outcome") + raise SchemaRuleError(rule("type", "/outcome", "expected type 'string'")) expected_body = { "completed": "seller_response", "failed": "error", "delivery": "delivery", }.get(outcome) if expected_body is None: - raise ValueError("invalid governance outcome") + raise SchemaRuleError( + rule("enum", "/outcome", "'outcome' is not one of 'completed', 'failed', 'delivery'") + ) version = raw.get("adcp_version") or resolved_version modern_version = ( isinstance(version, str) @@ -737,12 +850,24 @@ def validate_governance_outcome_request( if not legacy_delivery and not all( key in raw and raw[key] is not None for key in ("check_id", "governance_context") ): - raise ValueError("governance outcome requires check_id and governance_context") + raise SchemaRuleError( + *( + rule("required", f"/{key}", f"'{key}' is a required property") + for key in ("check_id", "governance_context") + if raw.get(key) is None + ) + ) populated = [ key for key in ("seller_response", "error", "delivery") if raw.get(key) is not None ] if populated != [expected_body]: - raise ValueError(f"{outcome} outcome requires exactly the {expected_body} body") + raise SchemaRuleError( + rule( + "oneOf", + f"/{expected_body}", + f"a {outcome} outcome carries exactly the '{expected_body}' body", + ) + ) if outcome == "delivery": delivery = raw["delivery"] if ( @@ -753,8 +878,16 @@ def validate_governance_outcome_request( or delivery.get("seller_statement_digest") is None ) ): - raise ValueError( - "seller_statement_copy requires seller_statement_id and seller_statement_digest" + raise SchemaRuleError( + *( + rule( + "dependencies", + f"/delivery/{key}", + f"'{key}' is required when 'source' is 'seller_statement_copy'", + ) + for key in ("seller_statement_id", "seller_statement_digest") + if delivery.get(key) is None + ) ) return request @@ -793,13 +926,25 @@ def governance_request_check_type( and any(key in raw for key in ("planned_delivery", "phase", "delivery_metrics")) ) if intent and execution: - raise ValueError("check_governance request cannot mix intent and execution fields") + raise SchemaRuleError( + rule( + "oneOf", + "/", + "check_governance request cannot mix intent and execution fields", + ) + ) if intent: return "intent" if execution: return "execution" if modern_version: - raise ValueError("AdCP 3.2 governance request must select an intent or execution shape") + raise SchemaRuleError( + rule( + "anyOf", + "/", + "AdCP 3.2 governance request must select an intent or execution shape", + ) + ) return None diff --git a/src/adcp/server/auth.py b/src/adcp/server/auth.py index 4f2e89209..35132b631 100644 --- a/src/adcp/server/auth.py +++ b/src/adcp/server/auth.py @@ -985,7 +985,6 @@ async def enforce_authenticated_tenant( raise AdcpError( "PERMISSION_DENIED", message="Bearer credential is not valid for this tenant.", - recovery="correctable", ) return await call_next() diff --git a/src/adcp/server/base.py b/src/adcp/server/base.py index 94fad3a99..979890e38 100644 --- a/src/adcp/server/base.py +++ b/src/adcp/server/base.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from typing import Any, Generic, TypeVar -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError # Request types are imported at module scope (NOT under TYPE_CHECKING) # so that ``typing.get_type_hints(method)`` resolves every ADCPHandler @@ -210,6 +210,94 @@ class NotImplementedResponse(BaseModel): error: Error | None = None +def invalid_request( + error: Exception, + *, + request_model: type[BaseModel] | None = None, + payload: Any = None, +) -> NotImplementedResponse: + """The one refusal a request-validation path returns to a peer. + + Every ``handler.<tool>()`` in this package validates ``params`` into its + request model and must answer a rejection with a protocol error rather + than let the exception escape as a transport failure. This builds that + answer once. + + A pydantic ``ValidationError`` is projected to value-free ``issues[]`` + (see :mod:`adcp.validation.pydantic_errors`) and then through the same + envelope builder the jsonschema oracle path uses, so the two routes to + ``VALIDATION_ERROR`` agree. ``str(error)`` is never used for one: it + renders pydantic's diagnostic including ``input_value``, which reflects + the peer's own payload — a bearer token, PII, a prompt-injection string — + back out through an envelope that logs and intermediaries see. + + Any other exception is a rule raised by this SDK's own validators, whose + text is authored and value-free, so it is carried as the single issue's + message. Those callers pass ``request_model`` because only a pydantic + error knows which model refused it. + + Args: + error: The rejection. A pydantic ``ValidationError`` names its own + model via ``.title``, so the tool name is derived, not declared. + request_model: Required only when ``error`` is not a pydantic + ``ValidationError``. + """ + from adcp.types.versioned import _schema_key_for_model_name + from adcp.validation.pydantic_errors import issues_from_validation_error + from adcp.validation.schema_errors import build_adcp_validation_error_payload + from adcp.validation.schema_validator import SchemaRuleError, ValidationIssue + + issues: list[ValidationIssue] + if isinstance(error, ValidationError): + model_name = request_model.__name__ if request_model is not None else error.title + # The buyer's document, so the loc resolves against what was actually + # sent. Read for KEY MEMBERSHIP only -- the resolver appends elements of + # the loc and never anything out of the document -- which is what keeps + # the issue rows value-free while making the pointer resolvable. + issues = issues_from_validation_error(error, payload) + elif request_model is not None: + model_name = request_model.__name__ + if isinstance(error, SchemaRuleError): + # The rule named its own vocabulary term and pointer, and a site that + # enforces two rules carries two rows. Nothing is stamped here. + issues = list(error.issues) + else: + # An exception this package did not raise -- `invalid_request` is public + # API, so an adopter can pass one -- and there is nothing to derive a + # keyword from. `validation` is NOT a JSON Schema vocabulary term and + # core/error.json says not to invent values, so this row is known to be + # non-conformant; it survives because the alternative is guessing a term + # for a rule this package cannot see. salesagent-8qela.117. + issues = [ + ValidationIssue( + pointer="/", + message=str(error), + keyword="validation", + schema_path="", + ) + ] + else: + # A caller bug, not a peer's. Loud rather than mislabelled: without + # either source there is no tool name to put in the envelope. + raise TypeError( + "invalid_request needs request_model for a non-ValidationError " + f"rejection, got {type(error).__name__}" + ) + + tool, side = _schema_key_for_model_name(model_name) + + payload = build_adcp_validation_error_payload(tool, side, issues) + return NotImplementedResponse( + supported=False, + # The builder's message, not "Invalid request: {e}" — the old spelling + # interpolated the exception and so echoed the payload here too. + reason=payload["message"], + # Every envelope field comes from the builder, so this lane and the + # jsonschema oracle answer a rejection identically. + error=Error(**payload), + ) + + def not_supported( reason: str = "This operation is not supported by this agent", ) -> NotImplementedResponse: diff --git a/src/adcp/server/content_standards.py b/src/adcp/server/content_standards.py index eb9fa7d67..73c4f169b 100644 --- a/src/adcp/server/content_standards.py +++ b/src/adcp/server/content_standards.py @@ -11,13 +11,12 @@ from pydantic import ValidationError -from adcp.server.base import ADCPHandler, NotImplementedResponse, TContext +from adcp.server.base import ADCPHandler, NotImplementedResponse, TContext, invalid_request from adcp.types import ( CalibrateContentRequest, CalibrateContentResponse, CreateContentStandardsRequest, CreateContentStandardsResponse, - Error, GetContentStandardsRequest, GetContentStandardsResponse, GetMediaBuyArtifactsRequest, @@ -71,11 +70,7 @@ async def create_content_standards( try: request = CreateContentStandardsRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_create_content_standards(request, context) async def get_content_standards( @@ -90,11 +85,7 @@ async def get_content_standards( try: request = GetContentStandardsRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_get_content_standards(request, context) async def list_content_standards( @@ -109,11 +100,7 @@ async def list_content_standards( try: request = ListContentStandardsRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_list_content_standards(request, context) async def update_content_standards( @@ -128,11 +115,7 @@ async def update_content_standards( try: request = UpdateContentStandardsRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_update_content_standards(request, context) async def calibrate_content( @@ -147,11 +130,7 @@ async def calibrate_content( try: request = CalibrateContentRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_calibrate_content(request, context) async def validate_content_delivery( @@ -166,11 +145,7 @@ async def validate_content_delivery( try: request = ValidateContentDeliveryRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_validate_content_delivery(request, context) async def get_media_buy_artifacts( @@ -185,11 +160,7 @@ async def get_media_buy_artifacts( try: request = GetMediaBuyArtifactsRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_get_media_buy_artifacts(request, context) # ======================================================================== diff --git a/src/adcp/server/governance.py b/src/adcp/server/governance.py index 07ab20441..7623cb70a 100644 --- a/src/adcp/server/governance.py +++ b/src/adcp/server/governance.py @@ -17,7 +17,7 @@ validate_governance_request, validate_governance_verdict, ) -from adcp.server.base import ADCPHandler, NotImplementedResponse, TContext +from adcp.server.base import ADCPHandler, NotImplementedResponse, TContext, invalid_request from adcp.types import ( CheckGovernanceRequest, CheckGovernanceResponse, @@ -25,7 +25,6 @@ CreatePropertyListResponse, DeletePropertyListRequest, DeletePropertyListResponse, - Error, GetCreativeFeaturesRequest, GetCreativeFeaturesResponse, GetPlanAuditLogsRequest, @@ -84,11 +83,7 @@ async def get_creative_features( try: request = GetCreativeFeaturesRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_get_creative_features(request, context) async def sync_plans( @@ -100,11 +95,7 @@ async def sync_plans( try: request = SyncPlansRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_sync_plans(request, context) async def check_governance( @@ -122,11 +113,7 @@ async def check_governance( if expected_check_type is not None: validate_governance_request(request) except (ValidationError, ValueError) as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params, request_model=CheckGovernanceRequest) response = await self.handle_check_governance(request, context) if not isinstance(response, NotImplementedResponse) and expected_check_type is not None: validate_governance_verdict( @@ -149,11 +136,7 @@ async def report_plan_outcome( resolved_version=getattr(context, "resolved_adcp_version", None), ) except (ValidationError, ValueError) as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params, request_model=ReportPlanOutcomeRequest) return await self.handle_report_plan_outcome(request, context) async def get_plan_audit_logs( @@ -165,11 +148,7 @@ async def get_plan_audit_logs( try: request = GetPlanAuditLogsRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_get_plan_audit_logs(request, context) async def create_property_list( @@ -184,11 +163,7 @@ async def create_property_list( try: request = CreatePropertyListRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_create_property_list(request, context) async def get_property_list( @@ -203,11 +178,7 @@ async def get_property_list( try: request = GetPropertyListRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_get_property_list(request, context) async def list_property_lists( @@ -222,11 +193,7 @@ async def list_property_lists( try: request = ListPropertyListsRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_list_property_lists(request, context) async def update_property_list( @@ -241,11 +208,7 @@ async def update_property_list( try: request = UpdatePropertyListRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_update_property_list(request, context) async def delete_property_list( @@ -260,11 +223,7 @@ async def delete_property_list( try: request = DeletePropertyListRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_delete_property_list(request, context) # ======================================================================== diff --git a/src/adcp/server/idempotency/store.py b/src/adcp/server/idempotency/store.py index 11c139b45..87fb683a7 100644 --- a/src/adcp/server/idempotency/store.py +++ b/src/adcp/server/idempotency/store.py @@ -381,7 +381,6 @@ async def _execute_locked() -> Any: "the outcome may be unknown. Retry with the same key only " "when downstream effects are independently deduplicated." ), - recovery="transient", ) from exc return response diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index 6b6794017..dc45399c3 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -1928,6 +1928,142 @@ def _prune_schema_node( return value +#: The two keywords a SPLIT ROOT's schema states its branches under. A root whose +#: branches partition the document space generates one class per branch, and its +#: JSON schema is the combinator over them and nothing else. +_BRANCH_COMBINATORS = ("anyOf", "oneOf") + + +#: Annotations that describe a field rather than constrain it. Hoisted out of the +#: per-branch declarations before they are compared, so two branches that differ +#: only in a default are still recognised as declaring the same type. +_FIELD_ANNOTATIONS = ("default", "title", "description") + + +def _widest_declaration(declared: list[dict[str, Any]]) -> dict[str, Any]: + """One field's declaration across the branches that declare it. + + A branch only ever NARROWS a root field -- it states what it changes, and + types what it forbids as ``None``. So the root's own declaration is the UNION + of its branches', and combining them recovers it rather than guessing at it. + Measured on ``create_media_buy``'s ``packages``: + + fixed-allocation branch {"items": {...}, "type": "array"} + seller-optimized branch {"items": {...}, "type": "array"} + committed-proposal branch {"default": null, "type": "null"} + the root class declares Sequence[PackageRequest] | None + + No branch carries the root's shape and the union of the three is exactly it. + Taking the first branch's instead advertised ``packages`` as a required + non-nullable array and ``proposal_id`` as necessarily null -- the narrowing + of one branch presented as the contract of all three. + """ + annotations: dict[str, Any] = {} + for key in _FIELD_ANNOTATIONS: + for declaration in declared: + if key in declaration: + annotations[key] = declaration[key] + break + variants: list[dict[str, Any]] = [] + for declaration in declared: + constraint = {k: v for k, v in declaration.items() if k not in _FIELD_ANNOTATIONS} + if constraint and constraint not in variants: + variants.append(constraint) + if not variants: + return dict(annotations) + if len(variants) == 1: + return {**variants[0], **annotations} + return {"anyOf": variants, **annotations} + + +def _object_root_from_branches(schema: dict[str, Any]) -> dict[str, Any] | None: + """An unconditional object root for a split root, or ``None``. + + MCP discovery needs ``type: "object"`` with ``properties`` at the root so a + client can render the form, which is why :func:`_model_to_json_schema` + refuses a combinator there. But refusing the whole schema threw away the + fields too: the three split-root request models -- create_media_buy, + request_proposals, verify_brand_claim -- fell back to hand-crafted stubs, + and a client reading create_media_buy's ``inputSchema`` could not see + ``account`` at all (salesagent-8qela.85). + + Nothing is reconstructed. A branch's ``$defs`` entry is already a complete + object schema -- measured on CreateMediaBuyRequestExplicitPackagesWithFixedAllocation: + ``additionalProperties``, ``description``, ``properties`` (31, ``account`` + among them), ``required``, ``title``, ``type`` -- so this reads what the + union already carries rather than rendering a second schema. An earlier + attempt stashed the root's own core schema in the type layer and rendered it + separately; that is unsound, because the core schema the handler returns is + not self-contained and both a borrowed and a fresh generator raised + ``KeyError`` on a definition the enclosing build owns. + + PROPERTIES ARE UNIONED and REQUIRED IS INTERSECTED, which is what makes the + result honest about what it is: every field any branch accepts is + advertised, and only a field EVERY branch demands is advertised as required. + So the schema is permissive about which branch a document must satisfy -- a + client can build a payload satisfying none -- and that is discovery, not + validation: the branches are still enforced on arrival. It is also strictly + better than the stub it replaces, which was permissive about everything and + named the wrong fields. + + The division of labour, because conflating the two is what produced the stub: + THE ADVERTISED SCHEMA'S JOB IS TO LET A CLIENT BUILD A CANDIDATE, AND THE + SERVER'S JOB IS TO REFUSE A WRONG ONE. A schema that admits a payload + satisfying no branch is a smaller defect than one that cannot describe the + request at all. + + AND THE ADVERTISED SCHEMA IS NOT THE PINNED DOCUMENT. They are different + artifacts with different consumers, and the pin is not the authority here: + ``create-media-buy-request.json`` carries ``anyOf`` AND ``allOf`` at its + root, so a pin-faithful projection is exactly what + ``test_every_input_discovery_schema_has_unconditional_object_root`` refuses. + "Match the pin" sounds unarguable and is the wrong instruction for this + function; matching it in ``model_json_schema()`` is a separate obligation + (salesagent-8qela.90). + """ + combinator = next((kw for kw in _BRANCH_COMBINATORS if kw in schema), None) + if combinator is None: + return None + branches = schema.get(combinator) + definitions = schema.get("$defs") or {} + if not isinstance(branches, list) or not branches: + return None + resolved: list[dict[str, Any]] = [] + for branch in branches: + # Only a bare ``$ref`` to a local object definition. Anything else -- an + # inline branch, a nested combinator, a non-object -- is a shape this + # has not been measured against, and returning None keeps the existing + # refusal rather than guessing. + if not isinstance(branch, dict) or set(branch) != {"$ref"}: + return None + target = definitions.get(str(branch["$ref"]).rsplit("/", 1)[-1]) + if not isinstance(target, dict) or target.get("type") != "object": + return None + if not target.get("properties"): + return None + resolved.append(target) + + properties: dict[str, Any] = {} + for name in dict.fromkeys(key for target in resolved for key in target["properties"]): + declared = [ + target["properties"][name] for target in resolved if name in target["properties"] + ] + properties[name] = _widest_declaration(declared) + required = set(resolved[0].get("required") or ()) + for target in resolved[1:]: + required &= set(target.get("required") or ()) + + root = {key: value for key, value in schema.items() if key != combinator} + root["type"] = "object" + root["properties"] = properties + if required: + root["required"] = sorted(required) + for inherited in ("additionalProperties", "description", "title"): + if inherited not in root and inherited in resolved[0]: + root[inherited] = resolved[0][inherited] + return root + + def _model_to_json_schema( model_type: Any, *, @@ -1957,6 +2093,10 @@ def _model_to_json_schema( Returns ``None`` on any failure — callers fall back to skipping. """ + # Annotated rather than inferred: the first assignment comes from an ``Any`` + # expression, so a later reassignment resets the flow narrowing the + # ``isinstance`` guard below establishes and the return becomes ``Any``. + schema: dict[str, Any] try: from pydantic import TypeAdapter @@ -1968,9 +2108,12 @@ def _model_to_json_schema( except Exception: return None - if not isinstance(schema, dict): - return None - + # An ``isinstance(schema, dict)`` guard stood here and mypy reports it + # unreachable under the annotation above: both ``model_json_schema()`` and + # ``TypeAdapter.json_schema()`` are typed to return ``dict[str, Any]``, and + # anything else they could raise is already caught. Keeping it would mean + # keeping the annotation off, which is what let the return type decay to + # ``Any``. schema.pop("title", None) if not allow_root_union: @@ -1985,7 +2128,20 @@ def _model_to_json_schema( "else", "dependentSchemas", } - if schema.get("type") != "object" or unsupported_root_keywords.intersection(schema): + present = unsupported_root_keywords.intersection(schema) + if present and set(present) <= set(_BRANCH_COMBINATORS): + # A split root: recoverable, because its branches carry the object + # surface the root's own schema has no room for. A conditional + # (``if``/``then``, ``not``, ``dependentSchemas``) or a composition + # (``allOf``, ``$ref``) constrains the SAME object and is NOT + # recoverable -- dropping one would advertise a surface the seller + # does not accept, which is the contract + # ``test_pydantic_fallback_rejects_conditional_root`` holds. + recovered = _object_root_from_branches(schema) + if recovered is None: + return None + schema = _drop_unreachable_defs(recovered) + elif present or schema.get("type") != "object": return None if schema_mode not in _SCHEMA_MODES: diff --git a/src/adcp/server/principal.py b/src/adcp/server/principal.py index 840499ca6..e3ff2ece9 100644 --- a/src/adcp/server/principal.py +++ b/src/adcp/server/principal.py @@ -19,7 +19,7 @@ from urllib.parse import urlsplit, urlunsplit from uuid import uuid4 -from pydantic import BaseModel, TypeAdapter +from pydantic import BaseModel, TypeAdapter, ValidationError from adcp._deferred_adapters import deferred_adapter from adcp.server.idempotency import canonical_json_sha256 @@ -158,6 +158,31 @@ def _json(value: object) -> str: return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) +def _first_message(error: ValidationError, payload: Any) -> str: + """The first refusal a pydantic error carries, with the path it sits on. + + Goes through the same projection the handler lane uses + (:func:`adcp.validation.pydantic_errors.issues_from_validation_error`), so + the message is built from schema-side facts and the path is resolved + against ``payload``. Passing pydantic's own ``msg`` and ``loc`` through + instead put producer-internal names on the wire: ``msg`` for a type + mismatch reads "instance of Configuration", and ``loc`` carries the + selected union branch's class name, e.g. ``AgentReportingDestination1``. + + ``field`` uses JSONPath-lite, so the location is written that way here too + rather than as the RFC 6901 pointer ``issues[].pointer`` carries. + """ + from adcp.validation.pydantic_errors import issues_from_validation_error + from adcp.validation.schema_validator import pointer_to_field + + issues = issues_from_validation_error(error, payload) + if not issues: + return "the request could not be validated" + first = issues[0] + location = pointer_to_field(first.pointer) + return f"{location}: {first.message}" if location else first.message + + def _failed(code: str, message: str, *, context: object | None = None) -> SyncPrincipalResponse: return SyncPrincipalResponse.model_validate( { @@ -434,7 +459,24 @@ async def sync_principal( identity: PrincipalIdentity, request: SyncPrincipalRequest | Mapping[str, Any], ) -> SyncPrincipalResponse: - request = SyncPrincipalRequest.model_validate(request) + try: + request = SyncPrincipalRequest.model_validate(request) + except ValidationError as error: + # A request this service cannot parse is an INVALID_REQUEST, which + # is already how it answers every rule it checks itself: the + # ``ValueError`` its own normalizers raise becomes ``_failed`` + # below. The parse was outside that conversion because nothing + # about a document's CONTENT used to refuse here -- the generated + # models dropped their schemas' conditionals, so only a shape error + # could fail, and a shape error never reached this method. + # + # Now it can: ``agent-notification-config.json`` states that a + # config subscribing to account events carries + # ``all_authorized_accounts: true``, which the model enforces, so a + # buyer sending one without it parses no further. Raising a pydantic + # error out of a protocol handler would answer that with a transport + # failure instead of the rejected response the tool declares. + return _failed("INVALID_REQUEST", _first_message(error, request), context=None) if not request.configuration.model_fields_set: return _failed( "INVALID_REQUEST", diff --git a/src/adcp/server/signed_requests.py b/src/adcp/server/signed_requests.py index da3d3c9d9..4adba2c55 100644 --- a/src/adcp/server/signed_requests.py +++ b/src/adcp/server/signed_requests.py @@ -540,7 +540,6 @@ async def __call__(self, scope: Any, receive: Any, send: Any) -> None: authorization_error=AdcpError( "PERMISSION_DENIED", message="Bound origin requires Web Bot Auth", - recovery="correctable", ), ) return @@ -707,7 +706,6 @@ def jwks_resolver(keyid: str) -> dict[str, Any] | None: return replace(signer, agent_url=key.agent_url), AdcpError( "PERMISSION_DENIED", message="Bound origin requires Web Bot Auth", - recovery="correctable", ) return replace(signer, agent_url=key.agent_url if key is not None else None), None @@ -742,7 +740,6 @@ async def _authorize_wba_signer( return replace(signer, principal_id=principal), AdcpError( "SERVICE_UNAVAILABLE", message="Origin binding evaluation unavailable", - recovery="transient", ) try: record = await config.trust_records.evaluate( @@ -770,7 +767,6 @@ async def _authorize_wba_signer( return replace(signer, principal_id=principal), AdcpError( "SERVICE_UNAVAILABLE", message="Operator trust record is temporarily unavailable", - recovery="transient", ) except TrustRecordError as exc: self._binding_membership[signer.agent_url or ""] = False @@ -779,7 +775,6 @@ async def _authorize_wba_signer( return replace(signer, principal_id=None), AdcpError( "PERMISSION_DENIED", message="Signer origin is not authorized by its operator", - recovery="correctable", ) self._binding_membership[signer.agent_url or ""] = True config._delisted_task_origins.discard(signer.agent_url or "") @@ -809,7 +804,6 @@ def _identity_established( else AdcpError( "PERMISSION_DENIED", message="Caller is not authorized for this operation", - recovery="correctable", ) ) return replace(signer, principal_id=None), error @@ -884,12 +878,9 @@ async def _project_wba_authentication_error( AdcpError( "SERVICE_UNAVAILABLE", message="Signer identity is temporarily unavailable", - recovery="transient", ) if isinstance(failure, WbaUnavailableError) - else AdcpError( - "PERMISSION_DENIED", message="Authentication failed", recovery="correctable" - ) + else AdcpError("PERMISSION_DENIED", message="Authentication failed") ) scope.pop(VERIFIED_SIGNER_SCOPE_KEY, None) scope.pop(_FRAMEWORK_VERIFIED_SCOPE_KEY, None) diff --git a/src/adcp/server/sponsored_intelligence.py b/src/adcp/server/sponsored_intelligence.py index 313e39ec9..f74c3a677 100644 --- a/src/adcp/server/sponsored_intelligence.py +++ b/src/adcp/server/sponsored_intelligence.py @@ -11,9 +11,8 @@ from pydantic import ValidationError -from adcp.server.base import ADCPHandler, NotImplementedResponse, TContext +from adcp.server.base import ADCPHandler, NotImplementedResponse, TContext, invalid_request from adcp.types import ( - Error, SiGetOfferingRequest, SiGetOfferingResponse, SiInitiateSessionRequest, @@ -65,11 +64,7 @@ async def si_get_offering( try: request = SiGetOfferingRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_si_get_offering(request, context) async def si_initiate_session( @@ -84,11 +79,7 @@ async def si_initiate_session( try: request = SiInitiateSessionRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_si_initiate_session(request, context) async def si_send_message( @@ -103,11 +94,7 @@ async def si_send_message( try: request = SiSendMessageRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_si_send_message(request, context) async def si_terminate_session( @@ -122,11 +109,7 @@ async def si_terminate_session( try: request = SiTerminateSessionRequest.model_validate(params) except ValidationError as e: - return NotImplementedResponse( - supported=False, - reason=f"Invalid request: {e}", - error=Error(code="VALIDATION_ERROR", message=str(e)), - ) + return invalid_request(e, payload=params) return await self.handle_si_terminate_session(request, context) # ======================================================================== diff --git a/src/adcp/signing/_resolver_protocol.py b/src/adcp/signing/_resolver_protocol.py index 6eafd3c22..aa366226b 100644 --- a/src/adcp/signing/_resolver_protocol.py +++ b/src/adcp/signing/_resolver_protocol.py @@ -3,7 +3,8 @@ from __future__ import annotations import codecs -from collections.abc import AsyncIterator +from collections import deque +from collections.abc import AsyncIterator, Iterator from typing import Any import httpx @@ -13,17 +14,25 @@ from adcp.signing._strict_json import parse_strict_json -def protocol_validation_failed(error: BaseException) -> bool: - """Recognize SDK parse failures, including task-group wrappers on 3.10+.""" - pending = [error] +def _reachable(error: BaseException) -> Iterator[BaseException]: + """Every exception reachable from ``error``, breadth first. + + Follows group members, ``__cause__`` and ``__context__``, because the MCP + and A2A client stacks run the fetch inside an anyio task group and the + original failure can sit under any of the three. + + Breadth first and in declaration order so the first leaf yielded is the + nearest one: :func:`primary_cause` reports it, and a LIFO walk would report + whichever member happened to be pushed last. + """ + pending = deque([error]) seen: set[int] = set() while pending: - current = pending.pop() + current = pending.popleft() if id(current) in seen: continue seen.add(id(current)) - if isinstance(current, (ValueError, TypeError)): - return True + yield current children = getattr(current, "exceptions", ()) if isinstance(children, tuple): pending.extend(child for child in children if isinstance(child, BaseException)) @@ -31,7 +40,40 @@ def protocol_validation_failed(error: BaseException) -> bool: pending.append(current.__cause__) if current.__context__ is not None: pending.append(current.__context__) - return False + + +def _is_group(error: BaseException) -> bool: + return isinstance(getattr(error, "exceptions", None), tuple) + + +def protocol_validation_failed(error: BaseException) -> bool: + """Recognize SDK parse failures, including task-group wrappers on 3.10+.""" + return any(isinstance(found, (ValueError, TypeError)) for found in _reachable(error)) + + +def primary_cause(error: BaseException) -> BaseException: + """The nearest non-group exception under ``error``, or ``error`` itself. + + A task group's own ``str()`` is "unhandled errors in a TaskGroup (N + sub-exceptions)", which names neither the failure nor the agent, and every + fact a caller needs is in the member. Chaining the group instead would leave + each adopter to walk ``.exceptions`` — a copy of this traversal in every + consumer, which is the SDK's job and not theirs. + """ + return next((found for found in _reachable(error) if not _is_group(found)), error) + + +def describe_failure(error: BaseException) -> str: + """``error`` as a sentence that names the real failure. + + The type is included because a bare ``str()`` is empty for several of the + exceptions this path raises (``httpx.ConnectError()`` with no args, among + them), and a message that renders as "capabilities fetch failed: " is worse + than the group sentence it replaced. + """ + cause = primary_cause(error) + text = str(cause) + return f"{type(cause).__name__}: {text}" if text else type(cause).__name__ def _validate_protocol_object(content: bytes) -> dict[str, Any]: diff --git a/src/adcp/signing/agent_resolver.py b/src/adcp/signing/agent_resolver.py index 3a59c6279..7a40e520a 100644 --- a/src/adcp/signing/agent_resolver.py +++ b/src/adcp/signing/agent_resolver.py @@ -278,7 +278,9 @@ async def _fetch_capabilities( """ from adcp.signing._resolver_protocol import ( DiscoveryTransport, + describe_failure, fetch_protocol_capabilities, + primary_cause, protocol_validation_failed, ) @@ -329,10 +331,19 @@ async def _fetch_capabilities( if protocol_validation_failed(exc) else "capabilities_unreachable" ) + # The MCP and A2A stacks fetch inside an anyio task group, so `exc` + # is often a BaseExceptionGroup whose str() is "unhandled errors in + # a TaskGroup (1 sub-exception)" -- naming neither the failure nor + # the agent. AdCP 3.2.1's discovery-chain rejection table assigns + # request_signature_capabilities_unreachable the detail fields + # agent_url, http_status, dns_error and last_attempt_at, and + # http_status/dns_error live in the member; a caller handed only the + # group cannot populate them. So the message names the member and + # __cause__ IS the member, while the group stays in __context__. raise AgentResolverError( code, - f"get_adcp_capabilities failed: {exc}", - ) from exc + f"get_adcp_capabilities failed: {describe_failure(exc)}", + ) from primary_cause(exc) return _CapabilitiesPayload(body=body, final_url=discovery.final_url) diff --git a/src/adcp/signing/trust.py b/src/adcp/signing/trust.py index 4e17470c5..07833d75c 100644 --- a/src/adcp/signing/trust.py +++ b/src/adcp/signing/trust.py @@ -725,7 +725,6 @@ def require(self) -> None: raise AdcpError( "SERVICE_UNAVAILABLE", message="Trust record is temporarily unavailable", - recovery="transient", ) raise AdcpError( "PERMISSION_DENIED", @@ -735,7 +734,6 @@ def require(self) -> None: if self.include_grant_absent_details else None ), - recovery="correctable", ) diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index 71232446f..702ac0dbd 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -46,7 +46,10 @@ class name, or a numbered variant has no semantic alias — import it by its - FieldModel lists accept strings: ListCreativesRequest(fields=["creative_id", "name"]) # Works! - See adcp.types._ergonomic for implementation details. + The coercion is declared on the generated field itself -- see +COMPATIBILITY_ANNOTATIONS in scripts/post_generate_fixes.py. Most of it needs +no declaration at all: a generated enum is a StrEnum, so a string is accepted +natively, and pydantic builds a model field from a dict natively. """ from __future__ import annotations @@ -111,6 +114,7 @@ class name, or a numbered variant has no semantic alias — import it by its "RefineProposalsRequest", "RefineProposalsResponse", "RequestProposalsRequest", + "RequestProposalsRequestBranches", "RequestProposalsResponse", "UnsupportedRefinementDimensionDetails", "RegistryAcceptancePolicyProfileReference", @@ -159,6 +163,7 @@ class name, or a numbered variant has no semantic alias — import it by its "ListAccountsResponse", "ListAccountChangesRequest", "ListAccountChangesResponse", + "ListAccountChangesResponseBranches", "PaymentTerms", "ReportUsageRequest", "ReportUsageResponse", @@ -206,6 +211,7 @@ class name, or a numbered variant has no semantic alias — import it by its "ByCatalogItemItem", "ByPackageItem", "CreateMediaBuyRequest", + "CreateMediaBuyRequestBranches", "CreateMediaBuyResponse", "DailyBreakdownItem", "GetMediaBuyDeliveryRequest", @@ -285,6 +291,7 @@ class name, or a numbered variant has no semantic alias — import it by its "GetBrandIdentityResponse", "GetRightsRequest", "GetRightsResponse", + "VerifyBrandClaimRequestBranches", "VerifyBrandClaimPayload", "VerifyBrandClaimRequest", "VerifyBrandClaimResponse", @@ -457,6 +464,7 @@ class name, or a numbered variant has no semantic alias — import it by its "CreativeAssignment", "CreativeFilters", "CreativeManifest", + "CreativeManifestNamedFormatManifest", "CreativePolicy", "CreativeVariant", "DevicePlatform", @@ -648,6 +656,7 @@ class name, or a numbered variant has no semantic alias — import it by its "VcpmPricingOption", "WebhookChallenge", "WebhookChallengeResponse", + "WebhookChallengeResponseBranches", # Status enums & simple types "SignalAvailabilityType", "SignalCatalogType", @@ -1155,7 +1164,7 @@ def __getattr__(name: str) -> object: """Lazily resolve the AdCP type surface (PEP 562). ``import adcp.types`` stays cheap; the generated Pydantic graph — and its - import-time coercion / forward-compat patching — is built on first access + type graph — is built on first access to any type *symbol*, by importing :mod:`adcp.types._eager`. Resolved names are cached into this module's namespace, so ``__getattr__`` fires at most once per name. diff --git a/src/adcp/types/_branch_dispatch.py b/src/adcp/types/_branch_dispatch.py new file mode 100644 index 000000000..82d2fe753 --- /dev/null +++ b/src/adcp/types/_branch_dispatch.py @@ -0,0 +1,249 @@ +"""One hook: a base with branches validates as the branch a document satisfies. + +A schema root whose branches partition the document space generates a base and +one class per branch. The base carries THIS hook, so the root's name resolves to +the matching branch wherever it appears -- called directly, nested in another +model, or validated through a ``TypeAdapter``. + +Two mechanisms, because no single one reaches all four. The core-schema hook +covers every path pydantic validates THROUGH; ``__new__`` covers ``Base(**data)``, +which allocates the object before validation runs and so cannot be reached by a +schema at all. For a long stretch only the hook was here, and the sentence above +was false of its last clause: direct construction returned a loose base whose +serializer could not serialize it. + +Overriding ``model_validate`` reaches only one of the four. Pydantic validates a +nested field and a ``TypeAdapter`` through the CORE SCHEMA, so an override is +invisible on both paths -- and most roots with branches are nested object +schemas, which is where the response side's override left a document satisfying +no branch to parse as the loose base. + +WHICH CLASS GETS THE BRANCHES' SCHEMA is decided by a fact about the class, not +by a registration: the base is the class that DECLARES ``_branch_models``. A +branch inherits that method and must get its own shape back; so must a +refinement declared elsewhere, and the canonical layer declares exactly one. + +Membership is not enough for that test. ``cls in branches`` -- the guard the +response side's ``model_validate`` override used, correctly, for years -- admits +both "is a branch" and "is an unrelated subclass", and only the second matters +once the schema itself is being replaced. A guard sufficient for one mechanism +is silently wrong for another. + +The branches subclass the base, so they do not exist while the base is being +built. The hook returns the base's own schema then, and the declaring module's +``model_rebuild(force=True)`` after the branches are defined is what installs +the union. That rebuild resolves a forward reference the class cannot name at +declaration time, in the module that declares it -- which is what +``model_rebuild`` is for, and is not the same thing as a foreign module patching +a field onto a class and forcing a rebuild to make the patch take. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, cast + +from pydantic import GetCoreSchemaHandler, TypeAdapter, WrapValidator +from pydantic_core import CoreSchema, core_schema + +from adcp.types._conditions import name_the_combinator + +if TYPE_CHECKING: + from pydantic import BaseModel + +__all__ = ["BranchDispatchMixin"] + + +def _serialize_as_this_class(value: Any, handler: Any) -> Any: + """Serialize through the class's own model schema rather than the union. + + ``handler`` here is built from the schema passed alongside this function -- + the plain model schema the declaring class would have had -- so it terminates + instead of re-entering the branches' union. A BRANCH instance reaching this + is serialized by the base's field set, which costs nothing: the base's fields + are the merged union of its branches' on 14 of the 19 roots, and on the other + 5 ``serialize_as_any=True`` dispatches on the runtime type before this is + consulted (measured: 52 branch-only field values, 0 lost). + """ + return handler(value) + + +class BranchDispatchMixin: + """Give the declaring class its branches' core schema.""" + + #: The fields of this class its SCHEMA ROOT requires -- as opposed to the + #: ones a branch requires in order to be selected. The generated base + #: declares its own; the default is empty so a class that mixes this in + #: without one says nothing rather than claiming something. + #: + #: It exists because the distinction is not recoverable downstream. A + #: combinator refusal carries every absent field the branches reported, + #: root-required and branch-selecting mixed into one list, and an + #: intersection over those errors cannot separate them -- measured against + #: each root's pinned ``required`` list, that derivation agrees on 8 of 18 + #: roots and fails in both directions (salesagent-8qela.116). So the + #: generator emits it and :func:`name_the_combinator` reads it. + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset() + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + """The branch classes this root resolves to. + + A base overrides this. The default is empty so a class that mixes this + in without declaring branches keeps its own shape. + """ + return () + + @classmethod + def _branch_combinator(cls) -> str: + """The JSON Schema keyword a refusal names: ``anyOf`` or ``oneOf``. + + ``core/error.json`` requires every ``issues[]`` entry to carry a keyword + drawn from the JSON Schema vocabulary. A document satisfying no branch + fails each branch at FIELD validation, so pydantic reports ``missing`` + and nothing names the root's combinator -- and ``missing`` is outside + that vocabulary. + + The union this hook builds carries the wrapper that supplies it. An + earlier version of this mixin built a BARE union and the keyword was + silently lost on every path through the base, while the per-branch + residual checks still named it -- so the refusals that named a keyword + were the ones that needed it least. + """ + return "anyOf" + + def __new__(cls, **data: Any) -> Any: + """Construct the branch the document satisfies, not the base. + + The core-schema hook below covers every path pydantic validates THROUGH: + a nested field, a ``TypeAdapter``, ``model_validate``. It cannot cover + ``Base(**data)``, because by the time validation runs the object has + already been allocated -- ``BaseModel.__init__`` validates with + ``self_instance=self``, so a successful direct construction left a BASE + instance whose serializer is the union of its branches. That object is + not merely loosely typed: the union serializer matches no branch, falls + back to inferring a model, re-enters the same union, and recurses. + Measured on the canonical create request -- + ``model_dump(mode="python")`` survives the fallback, ``mode="json"`` + raises ``ValueError: Circular reference detected (id repeated)`` and + ``model_dump_json()`` raises ``PydanticSerializationError`` + (salesagent-setka). Choosing the class is the only place that can be + fixed, so it is fixed here. + + Gated on the same question the hook asks -- does this class DECLARE + ``_branch_models`` -- so a branch, and a refinement declared elsewhere, + construct themselves. One identity question, one place. + + CEILING: direct construction validates TWICE. ``__new__`` has to choose + the branch before ``type.__call__`` can run that branch's ``__init__``, + and that ``__init__`` validates the same payload again into the object + returned here. It is idempotent, and ``model_validate`` remains + single-pass, so the cost falls on the rarer path. Removing it needs + pydantic to expose branch selection without building an instance. + """ + if not data or "_branch_models" not in cls.__dict__: + # No data: every operation that rebuilds an instance without one + # reaches here and must get a blank instance rather than a validation + # of ``{}``. Measured on a BASE instance, all five arrive with empty + # ``data``: ``model_construct``, ``copy.copy``, ``copy.deepcopy``, a + # pickle round trip, and ``model_copy``. (The same five on a BRANCH + # instance never reach this at all -- a branch does not declare the + # method, so the second condition short-circuits. A first probe that + # exercised only branch instances reported them unaffected, which was + # a statement about that probe's scope and not about the guard.) + # + # Not the declaring class: a branch keeps its own construction, for + # the same reason it keeps its own schema. + return super().__new__(cls) + try: + branches = cls._branch_models() + except NameError: + # The branches subclass this class, so a construction attempted while + # the module is still executing has none to choose from yet. + return super().__new__(cls) + if not branches: + return super().__new__(cls) + # The base's own validator IS the union the hook installed, so this asks + # the same question the other three paths ask, with the same refusal -- + # including the root's combinator keyword on a document satisfying no + # branch. Cast because the mixin is a plain class: it has no pydantic + # attributes of its own and is only ever mixed into a model, which is + # the one assumption this file makes about its host. + host = cast("type[BaseModel]", cls) + return host.__pydantic_validator__.validate_python(data) + + @classmethod + def __get_pydantic_core_schema__(cls, source: Any, handler: GetCoreSchemaHandler) -> CoreSchema: + if "_branch_models" not in cls.__dict__: + # A branch, or a refinement elsewhere. Its own shape, not the union. + # This is also why building the union cannot re-enter here: a branch + # does not declare the method, so asking it for its schema returns + # immediately. A re-entry guard was written for that case and then + # removed, because removing it does not recurse -- the structural + # test already prevents what the guard was watching for. + return handler(source) + try: + branches = cls._branch_models() + except NameError: + # The branches subclass this class, so they are not defined yet. The + # declaring module's model_rebuild(force=True) runs this again. + return handler(source) + if not branches: + return handler(source) + union: Any = branches[0] + for branch in branches[1:]: + union |= branch + wrapped = Annotated[ + union, + WrapValidator( + name_the_combinator(cls._branch_combinator(), branches, cls._ROOT_REQUIRED) + ), + ] + schema = dict(TypeAdapter(wrapped).core_schema) + # THE UNION IS THE VALIDATOR. It is not also the serializer, because then + # the class has no serializer for an instance OF ITSELF: the union's + # serializer matches no branch, falls back to inferring a model, looks the + # class up, finds this same union, and re-enters it. Measured on all + # nineteen roots at 3488ff9c3 -- ``model_dump(mode="json")`` and + # ``model_dump_json()`` raise, and ``model_dump(mode="python")`` returns + # the MODEL INSTANCE instead of a dict, silently (salesagent-8qela.95). + # + # ``AdCPBaseModel.model_dump`` defaults ``serialize_as_any=True``, so + # serialization dispatches on the RUNTIME type: for a loose base that is + # the base, which is why the base's own serializer is the one consulted + # and why it has to be able to serialize one. + # + # A loose base is reachable without validation at all -- ``model_construct``, + # ``copy.copy``, ``copy.deepcopy``, ``model_copy`` and an unpickled instance + # all produce one, which ``__new__``'s docstring above records measuring -- + # so closing one constructor leaves four open. The serializer is the one + # place all five arrive. + # + # ONLY the ``serialization`` slot is set. An earlier attempt returned + # ``no_info_wrap_validator_function(dispatch, handler(source))``, putting + # the base's model schema in the SCHEMA position so a function-wrap would + # serialize through it. That works for serialization and is wrong: a core + # schema is also what the JSON schema is generated from, and it moved + # ``model_json_schema()`` on all nineteen roots from + # ``{$defs, anyOf}`` with no top-level properties to + # ``{$defs, properties, required}`` -- dropping the root combinator the pin + # states. It cost four tests (``test_root_conjunct_defaults`` and three in + # ``test_targeting_overlay_errors``, whose primary field moved because the + # issue order moved) and it walks into the open design questions on + # salesagent-8qela.85, .88 and .90. Keeping the union in the schema + # position leaves the JSON schema and every refusal exactly as they were. + # + # AND IT IS SET ON THE SCHEMA THE WRAPPER HOLDS, not on the wrapper. 17 of + # the 19 roots come back as a ``definitions`` node wrapping the + # ``function-wrap``; the other two (``VerifyBrandClaimRequest``, + # ``WebhookChallengeResponse``) are the ``function-wrap`` itself. A + # ``serialization`` key on a ``definitions`` node is not consulted, so + # setting it there fixed exactly those two and left 17 broken -- which is + # how the split was found. + serializer = core_schema.wrap_serializer_function_ser_schema( + _serialize_as_this_class, schema=handler(source) + ) + if schema["type"] == "definitions": + schema["schema"] = {**schema["schema"], "serialization": serializer} + else: + schema["serialization"] = serializer + return schema diff --git a/src/adcp/types/_conditions.py b/src/adcp/types/_conditions.py new file mode 100644 index 000000000..3eb66e9bc --- /dev/null +++ b/src/adcp/types/_conditions.py @@ -0,0 +1,866 @@ +"""The vocabulary compiled root constraints are written in. + +A schema root states more than its merged ``properties``: a root +``anyOf``/``oneOf`` over overlay branches, a root ``if``/``then``/``else``, root +``dependencies``, a root ``not``. ``scripts/schema_constraints.py`` reads that +content and the generator compiles it into a ``model_validator`` on the class, +as compositions of the primitives below. Nothing here is generated; every +function is a few lines, and each is graded one by one by +``tests/test_conditions_vocabulary.py``. + +Two conventions run through all of them, and both are measured rather than +chosen. + +**Presence is what the DOCUMENT carries.** ``required`` asks whether the document +carries the property, so ``model_fields_set`` answers it for everything the +caller typed -- and an explicit null is one of those, because on an AdCP 3.2 +mutation input it is the command to clear a stored value. It is not the whole +answer, because a default the class EMITS is on the document too, and the +document is all a seller can see: the two dumps are byte-identical, so a +constraint whose verdict differs between them is grading something the wire +cannot express (salesagent-k37fs). So an unset field is asked of the serializer, +which is the only authority on what the class emits -- see :func:`_emitted`. + +The alternatives were measured and both are wrong: an ``is None`` guard is +correct only when the field's default happens to be ``None``, because a non-None +default makes it false for an absent field and the check over-refuses; and an +unguarded attribute read raises on absence and on an explicit null alike. + +**Every predicate is vacuous off-type.** JSON Schema applies a keyword only to +instances of the type it grades: ``minLength`` says nothing about a number, +``properties`` grades present object members only, ``items`` says nothing about +a non-array. So a primitive handed a value of the wrong shape returns ``True``. +That is the schema's own reading, and it is what lets the generator compile a +nested predicate without first resolving the nested annotation. +""" + +from __future__ import annotations + +import datetime +import decimal +import ipaddress +import pathlib +import re +import typing +import uuid +from collections import Counter +from collections.abc import Callable, Iterable, Mapping, Sequence +from typing import Any + +from pydantic import AnyUrl, ValidationError +from pydantic_core import PydanticCustomError, PydanticUndefined +from pydantic_core.core_schema import ErrorType + +__all__ = [ + "branch_title", + "name_the_combinator", + "absent", + "descend", + "every", + "json_type", + "keys", + "length", + "matches", + "matching_keys", + "members", + "only", + "others", + "sent", + "some", + "supplied", + "unique", + "within", +] + +#: What a predicate over one value looks like. +Predicate = Callable[[Any], bool] + +#: Python types a JSON STRING arrives as once pydantic has parsed it. A +#: compiled ``type`` check runs after validation, so ``isinstance(value, str)`` +#: alone is false for every ``format``-bearing string field -- the model then +#: refuses a document its schema accepts, permanently and silently. +#: +#: Measured on the pin: seven fields over six modules were in exactly that +#: state -- ``reporting-revision``'s ``data_through``, ``search-brands-response``'s +#: ``relationship_verified_at`` and its logo ``url``, and four ``timestamp`` +#: fields -- each annotated ``AwareDatetime`` or ``AnyUrl`` and each carrying a +#: root ``type: "string"`` rule it could never satisfy. +#: +#: Enumerated rather than defined by exclusion. "Not object, array, number, +#: boolean or null, therefore string" is true of JSON and wrong here, because +#: a JSON NUMBER also arrives as a non-``float``: pydantic parses one into +#: ``Decimal`` where the field says so, and exclusion would call that a string. +_STRING_BACKED = ( + str, + datetime.datetime, + datetime.date, + datetime.time, + datetime.timedelta, + uuid.UUID, + AnyUrl, + pathlib.PurePath, + ipaddress.IPv4Address, + ipaddress.IPv6Address, +) + +#: Python types a JSON NUMBER arrives as. ``Decimal`` is neither ``int`` nor +#: ``float``, and a field declared with a multiple-of or a money precision gets +#: one. +_NUMBER_BACKED = (int, float, decimal.Decimal) + +#: JSON type names, and the Python instances that answer to them. ``integer`` +#: excludes ``bool``, which is a subclass of ``int`` in Python and a separate +#: type in JSON. +_JSON_TYPES: dict[str, Callable[[Any], bool]] = { + "object": lambda value: isinstance(value, Mapping) or _is_model(value), + "array": lambda value: isinstance(value, (list, tuple)), + "string": lambda value: isinstance(value, _STRING_BACKED), + "integer": lambda value: ( + isinstance(value, (int, decimal.Decimal)) + and not isinstance(value, bool) + and int(value) == value + ), + "number": lambda value: isinstance(value, _NUMBER_BACKED) and not isinstance(value, bool), + "boolean": lambda value: isinstance(value, bool), + "null": lambda value: value is None, +} + + +def _is_model(value: Any) -> bool: + return hasattr(value, "model_fields_set") + + +def _emitted(value: Any, name: str) -> bool: + """Whether ``value``'s own serialization carries ``name`` the caller did not set. + + The serializer is ASKED rather than the default inspected, because the two + disagree and only the serializer is right. ``GetMediaBuyDeliveryRequest`` + defaults ``include_window_breakdown`` to ``False`` and its wrap serializer + drops that field when it is unset beside a ``reporting_revision_id``: the + default exists, the wire does not carry it, and a constraint reading it as + present would accept a document the schema refuses. Inspecting the default + gets that case backwards; one ``model_dump`` gets it right, and gets a + ``default_factory`` and ``exclude_none`` right with it. + + ``include`` narrows the dump to the one field, so this costs that field's + serialization and not the model's. Narrowing could only change an answer + where a serializer decides about THIS field by reading another one out of the + dict it was handed, so it was checked against all three wrap serializers in + the tree that drop a field, and the narrowed dump agrees with the full one on + each: two decide from ``self`` -- ``AdCPBaseModel`` reads ``event_types``, and + flipping it flips this answer in both directions -- and ``Scope``'s reads only + the key it is deciding about. + + The default check ahead of it is a cost guard, not the question: a field with + nothing to emit is dropped by ``exclude_none`` anyway, and that is the common + case on every optional field. Identity rather than ``in``, because a default + is an arbitrary object and ``==`` against it is the caller's code. + """ + fields = getattr(type(value), "model_fields", None) + info = fields.get(name) if fields else None + if info is None: + return False + if info.default_factory is None and (info.default is None or info.default is PydanticUndefined): + return False + return name in value.model_dump(mode="json", exclude_none=True, include={name}) + + +def supplied(value: Any, name: str) -> bool: + """Whether the document carried ``name`` on ``value``. + + A pydantic model answers from ``model_fields_set`` -- the fields the caller + actually set -- and then from its serializer, because a default the class + emits is carried by the document just as a supplied value is. A mapping -- an + open payload the models carry through unmodelled -- answers from its keys. + """ + if _is_model(value): + return name in value.model_fields_set or _emitted(value, name) + if isinstance(value, Mapping): + return name in value + return False + + +def sent(value: Any, name: str) -> bool: + """Whether ``name`` would be PRESENT on the document that is sent. + + ``supplied`` answers membership, which is the whole question at a position + the schema marks nullable: there an explicit null is the caller's value and + the client keeps the key. Where the schema does NOT permit null, the client + drops the key -- ``model_dump(exclude_none=True)`` removes it and + ``preserve_explicit_nulls`` restores only nullable paths -- so a null-valued + field is absent from what is sent, and a constraint reading it as present + refuses a request no seller receives. + + Still presence, not an ``is None`` test: :func:`supplied` decides whether the + document carries the name, and this adds only the non-null half. + """ + return supplied(value, name) and _member(value, name) is not None + + +def _member(value: Any, name: str) -> Any: + if isinstance(value, Mapping): + return value.get(name) + return getattr(value, name, None) + + +def descend(value: Any, name: str, predicate: Predicate) -> bool: + """Apply ``predicate`` to a supplied member of ``value``. + + Vacuous when the member was NOT SUPPLIED, because ``properties`` grades + present object members and says nothing about a missing one. An explicit + null is supplied, and the subschema applies to it like any other value: + ``{"properties": {"format_kind": {"const": "audio_vast"}}}`` is false for + ``{"format_kind": null}``, not vacuous. Each primitive already reads a + ``None`` the way JSON Schema does -- ``const`` and ``enum`` compare and + fail, ``type`` answers ``null``, and a bound on a string or an array is + vacuous off its own type -- so passing the null through is what makes the + composition match the schema. + + Reading a null as vacuous here was measured wrong on + ``creative-manifest.json``: its audio-VAST conjunct tests + ``format_kind == 'audio_vast'``, a manifest carrying + ``format_kind: null`` satisfied that test vacuously, and the model refused + the document under a rule its schema does not apply to it. + """ + if not supplied(value, name): + return True + return predicate(_member(value, name)) + + +def every(value: Any, predicate: Predicate) -> bool: + """``items``: every element satisfies ``predicate``.""" + if not isinstance(value, (list, tuple)): + return True + return all(predicate(element) for element in value) + + +def some(value: Any, predicate: Predicate) -> bool: + """``contains``: at least one element satisfies ``predicate``.""" + if not isinstance(value, (list, tuple)): + return True + return any(predicate(element) for element in value) + + +def _named_members(value: Any) -> Iterable[str]: + """The names the document carried, each once. + + pydantic puts an extra a model KEPT into ``model_fields_set`` as well as + ``model_extra``, so concatenating the two names every extra twice. Four of + the five callers quantify over these names and cannot see a duplicate; + :func:`members` lengths them, and for it a duplicate is a wrong count -- an + open model carrying two members read as three, so ``maxProperties: 2`` + refused a document the schema accepts. Both sources are still read, so a + name present in only one of them still counts. + + A default the class emits is a third source, for the reason :func:`supplied` + gives: ``core/deadline-policy.json`` states ``minProperties: 1`` and + ``DeadlinePolicy`` defaults ``business_days_only`` to ``False``, so the empty + construction dumps one member and ``_c.members`` counted none. + + The declared fields are read through ``getattr``, the same way :func:`_emitted` + reads them, because :func:`_is_model` is duck-typed: a subject can carry + ``model_fields_set`` and declare no fields, and then :func:`supplied` answers + for it while an unguarded ``type(value).model_fields`` raises. Every primitive + has to answer for every subject the predicate admits. + """ + if isinstance(value, Mapping): + return list(value) + if _is_model(value): + extras = getattr(value, "model_extra", None) or () + declared = getattr(type(value), "model_fields", None) or {} + unset = (name for name in declared if name not in value.model_fields_set) + emitted = [name for name in unset if _emitted(value, name)] + return list(dict.fromkeys([*value.model_fields_set, *extras, *emitted])) + return () + + +def keys(value: Any, predicate: Predicate) -> bool: + """``propertyNames``: every member name satisfies ``predicate``.""" + if not (isinstance(value, Mapping) or _is_model(value)): + return True + return all(predicate(name) for name in _named_members(value)) + + +def matching_keys(value: Any, pattern: str, predicate: Predicate) -> bool: + """``patternProperties``: every member whose name matches satisfies it.""" + if not (isinstance(value, Mapping) or _is_model(value)): + return True + expression = re.compile(pattern) + return all( + predicate(_member(value, name)) for name in _named_members(value) if expression.search(name) + ) + + +def only(value: Any, names: Sequence[str]) -> bool: + """``additionalProperties: false``: only ``names`` were supplied. + + A generated class normally declares more fields than one branch's + ``properties`` names, so this bounds what the caller supplied rather than + what the class can hold. An extra key the model carried through (an open + payload) counts as supplied. + """ + if not (isinstance(value, Mapping) or _is_model(value)): + return True + return set(_named_members(value)) <= set(names) + + +def others(value: Any, names: Sequence[str], predicate: Predicate) -> bool: + """``additionalProperties: <schema>``: unnamed members satisfy it.""" + if not (isinstance(value, Mapping) or _is_model(value)): + return True + permitted = set(names) + return all( + predicate(_member(value, name)) for name in _named_members(value) if name not in permitted + ) + + +def json_type(value: Any, *names: str) -> bool: + """``type``: the value is an instance of one of the named JSON types.""" + return any(_JSON_TYPES[name](value) for name in names if name in _JSON_TYPES) + + +def matches(value: Any, pattern: str) -> bool: + """``pattern``: an unanchored match, which is what JSON Schema specifies.""" + if not isinstance(value, str): + return True + return re.search(pattern, value) is not None + + +def length(value: Any, minimum: int | None = None, maximum: int | None = None) -> bool: + """``minLength``/``maxLength`` and ``minItems``/``maxItems``. + + Four bounds on a size, differing only in the type they grade -- and each is + vacuous off its own type, which a single ``len`` test over anything sized + reproduces exactly. + + NOT ``min``/``maxProperties``, which this docstring used to claim. A pydantic + object model has no ``__len__``, so those two returned ``True`` for every + object: the production existed, the renderer pointed here, and nothing was + graded. They are :func:`members` now, and the split is necessary rather than + tidy -- adding a model branch HERE would measure a ``RootModel``'s own single + field against an array's ``minItems``. + """ + try: + size = len(value) + except TypeError: + return True + if minimum is not None and size < minimum: + return False + return not (maximum is not None and size > maximum) + + +def members(value: Any, minimum: int | None = None, maximum: int | None = None) -> bool: + """``minProperties``/``maxProperties``: how many members the document carried. + + The same question :func:`supplied` asks, counted instead of tested: a + model answers from ``model_fields_set`` plus its extras plus the defaults it + emits, a mapping from its keys. + + Vacuous off its own type, like every primitive here. A ``RootModel`` does not + reach this from a root conjunct -- ``flat_root_shape`` returns ``None`` for a + map or array root, so no root validator is emitted for one, and its count + renders as a field constraint on the key or element type instead. + """ + if not (isinstance(value, Mapping) or _is_model(value)): + return True + size = len(list(_named_members(value))) + if minimum is not None and size < minimum: + return False + return not (maximum is not None and size > maximum) + + +def within( + value: Any, + ge: float | None = None, + le: float | None = None, + gt: float | None = None, + lt: float | None = None, +) -> bool: + """``minimum``/``maximum``/``exclusiveMinimum``/``exclusiveMaximum``.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return True + if ge is not None and value < ge: + return False + if le is not None and value > le: + return False + if gt is not None and value <= gt: + return False + return not (lt is not None and value >= lt) + + +def unique(value: Any) -> bool: + """``uniqueItems``: no element equals another. + + Quadratic on purpose. A JSON array's elements need not be hashable -- an + array of objects is the common case here -- so a set cannot do it, and the + arrays this grades are bounded by their own ``maxItems``. + """ + if not isinstance(value, (list, tuple)): + return True + seen: list[Any] = [] + for element in value: + if element in seen: + return False + seen.append(element) + return True + + +def absent(data: Any, names: Sequence[str], nullable_names: Sequence[str] = ()) -> Any: + """``not: {required: [...]}``: these keys may not reach the document sent. + + A branch class types a field its branch forbids as ``None``, which is what + makes the combination unspellable at construction. It is not what refuses + a raw document: the generated models are ``extra='allow'`` where their + schemas say ``additionalProperties: true``, so pydantic would file an + undeclared key under extras and accept. So the shape's forbidden names are + refused here, before validation. + + Presence is read the same way ``sent`` reads it, because the negation of + ``required`` is about the same document. A name in ``names`` sits at a + position the schema forbids ``null``, so the client drops a null-valued key + and only a non-null value is carried. A name in ``nullable_names`` sits at + a position where a null survives, so its mere presence is what the ban + forbids. + + Returned unchanged when the document carries none of them, and when it is + not a mapping at all (a model being revalidated has already been through + this). + """ + if not isinstance(data, Mapping): + return data + carried = sorted( + [name for name in names if data.get(name) is not None] + + [name for name in nullable_names if name in data] + ) + if carried: + # The keyword is the schema construct this compiles, named at the raise + # site because only the raise site knows it. A bare ``ValueError`` + # reaches pydantic as ``value_error``, which has no JSON Schema + # equivalent, so the projection falls back to ``validation`` -- and + # ``core/error.json`` requires every ``issues[]`` entry's keyword to be + # "drawn from the JSON Schema vocabulary", which ``error-handling.mdx`` + # states as a MUST. The forbidden names ride in the context rather than + # in a sentence, the way ``name_the_combinator`` carries the absent + # ones, so a consumer reads them without parsing text and the wire + # message stays the one ``safe_message_for`` authors. + raise PydanticCustomError( + "not", + "{fields} is not accepted by this request shape", + {"fields": ", ".join(carried)}, + ) + return data + + +def branch_title(branch: type) -> str: + """The schema title of the branch a branch class was compiled from. + + The emitter opens each branch's docstring with the branch's ``title``, which is + where schema-derived text rides -- no identifier is invented for it, and the + title is the only phrase a reader can match against the schema. A branch + with no docstring falls back to its class name, which is itself derived from + that title. + """ + first_line = (branch.__doc__ or "").strip().splitlines() + return first_line[0].rstrip(".") if first_line else branch.__name__ + + +#: The error types ``ValidationError.from_exception_data`` resolves by name. +#: ``ErrorType`` is the public enumeration of them; a type outside it is a +#: ``PydanticCustomError`` someone minted, and has to be re-minted rather than +#: named. +_KNOWN_ERROR_TYPES = frozenset(typing.get_args(ErrorType)) + + +def _is_known(error: Any) -> bool: + """Whether ``from_exception_data`` can rebuild this error FROM ITS NAME. + + The name is not enough on its own. A custom error may REUSE a known name + with its own context: ``EmailStr`` reports ``value_error`` carrying + ``{'reason': ...}``, while pydantic's own ``value_error`` carries + ``{'error': <exception>}`` and refuses to be built without it. Naming such + an error raises ``TypeError: ValueError: 'error' required in context``, + which is how this was found -- three parity tests, after the rest of the + suite was green. + + So the record itself is asked: ``errors()`` attaches ``url``, the + documentation link for a known type, and omits it for a custom one. + Measured on all four combinations -- ``int_parsing`` (known, url), + ``EmailStr``'s ``value_error`` (custom, no url), a compiled ``anyOf`` + (custom, no url), and a bare ``ValueError`` (known, url). The name is kept + as a second condition, so an error with neither falls to the re-mint, which + always builds. + """ + return error["type"] in _KNOWN_ERROR_TYPES and "url" in error + + +def _is_an_absent_root_key(error: Any) -> bool: + """Whether a union error says a key of the ROOT OBJECT is absent. + + Inside a union every ``loc`` opens with the label of the choice that + produced it, so a branch's own root properties sit at length two and + anything longer is a position inside the document. A ``missing`` deeper + than that is content, not requiredness: an object nested three levels down + lacking a field ITS schema requires says this branch was reached and the + document is wrong inside it. + + This says WHERE, not WHY. Whether such a key is required by the SCHEMA ROOT + or only by the branch that reported it is a fact about the schema, not about + the error -- which is why the root's required set is passed in rather than + inferred here. This was named ``_is_branch_requiredness`` while it was read + as the latter. + """ + return error["type"] == "missing" and len(error["loc"]) == 2 + + +def _declines_the_document(error: Any, *, label_depth: int) -> bool: + """Whether this error says the branch was never REACHED. + + Two ways a branch declines the document rather than finding a fault inside + it, and both report at the branch's own root: + + * a required field of its own is absent -- ``missing`` one level below that + root; + * it FORBIDS a key the document carries -- ``_c.absent`` raises + ``PydanticCustomError("not", ...)`` as a whole-document error AT that + root. + + ``label_depth`` is the only difference between the two coordinate systems + this is asked in, which is why it is one function. A branch asked DIRECTLY + reports a document path, so its root is ``()`` and depth is 0. Inside the + union pydantic prefixes every ``loc`` with the choice's label, so the same + root is ``(label,)`` and depth is 1. Writing the rule twice is how the two + call sites came to disagree: the union path tested only requiredness, so a + branch whose ``_c.absent`` had fired read as REACHED and its refusal + displaced the error of the branch the document actually meant, and the + root's combinator keyword with it (salesagent-klblw). + + THE TYPE TEST ON ``not`` IS LOAD-BEARING, and the previous rule's untyped + "any error at the branch's own root" is why. A branch that IS reached and + fails its inherited root conjunct ALSO reports at that root -- measured, + ``PydanticCustomError("oneOf", ...)`` at ``loc ()`` on six branches when + asked directly. Reading that as "declined" is backwards: the branch got + past its own requiredness and the conjunct is the informative failure. + ``_c.absent`` is the one site that raises ``not`` (``absent`` above names + the keyword at the raise site precisely so it is identifiable here), and a + conjunct raises ``allOf``/``anyOf``/``oneOf``, so the keyword separates + them exactly. + """ + loc = error["loc"] + if error["type"] == "missing" and len(loc) == label_depth + 1: + return True + return error["type"] == "not" and len(loc) == label_depth + + +def _errors_of_the_reached_branch( + value: Any, branches: Sequence[type], merged: list[dict[str, Any]] +) -> list[dict[str, Any]] | None: + """The subset of ``merged`` belonging to the branch the document reached. + + Used only when the union's labels are degenerate, so the merged list holds + every branch's errors under one key and there is nothing to group by. The + branches are asked DIRECTLY to decide which of them got past its own + requiredness; their own errors then identify which merged entries are that + branch's, matched on the error's type and its path below the label. + + Selection only: what is reported is still the union's own errors, in the + caller's input mode. A branch asked directly reports a document path, so its + ``loc`` is the merged ``loc`` minus the label -- which is the match key. + + Returns ``None`` when it cannot decide, and the caller then keeps the + ungrouped bucket: no branch was reached, two were, or a branch's direct + verdict disagrees with the union's. A wrong narrowing is worse than none. + """ + reached: list[Counter[tuple[Any, ...]]] = [] + for branch in branches: + try: + branch.__pydantic_validator__.validate_python(value) # type: ignore[attr-defined] + except ValidationError as branch_exc: + own = branch_exc.errors() + if any(_declines_the_document(error, label_depth=0) for error in own): + continue + reached.append(Counter(tuple(error["loc"]) for error in own)) + else: + return None # accepts on its own while the union refused: do not guess + if len(reached) != 1: + return None + # A MULTISET, not a set. Sibling branches report the SAME nested error when + # they share the field it is under -- two branches of the create request + # both validate ``packages``, so the merged list holds two identical + # ``string_type`` entries for one bad ``geo_countries`` element. Matching on + # membership kept both and the caller received the one real fault twice. + # Taking at most as many copies as the branch itself reported drops the + # sibling's duplicate and keeps a genuine repeat -- a document with a + # repeated JSON key does produce two errors at one path, and that is the + # branch's own count. + remaining = reached[0] + selected: list[dict[str, Any]] = [] + for error in merged: + key = tuple(error["loc"])[1:] + if remaining[key] > 0: + remaining[key] -= 1 + selected.append(error) + return selected or None + + +def _in_the_document(error: Any) -> Any: + """One union error restated with a ``loc`` that is a path in the document. + + Drops the choice label positionally, which is where it always is, rather + than recognising it by spelling. Everything else -- the type, the context a + message is rendered from, the input -- is pydantic's own, so the restated + error is identical to the one the branch would have reported had it been + asked alone, in the mode the caller used. + """ + detail: dict[str, Any] = { + "type": error["type"], + "loc": tuple(error["loc"][1:]), + "input": error["input"], + } + context = error.get("ctx") + if _is_known(error): + if context is not None: + detail["ctx"] = context + else: + # A custom refusal -- a compiled root constraint, or a library's own. + # Its context is already substituted into the message it rendered, so + # the message IS the template; a brace left in it stays literal, which + # is measured rather than assumed. + detail["type"] = ( + PydanticCustomError(error["type"], error["msg"], context) + if context is not None + else PydanticCustomError(error["type"], error["msg"]) + ) + return detail + + +def name_the_combinator( + keyword: str, + branches: Sequence[type] = (), + root_required: frozenset[str] = frozenset(), +) -> Callable[[Any, Any], Any]: + """Build the wrapping validator a split root's union carries. + + The refusal reads the same whether the root was SPLIT into branch classes or + left flat with a compiled ``_require_schema_required_group`` validator: + "requires at least one of these field groups: <title> | <title>". A root + moving from one mechanism to the other must not change what a reader sees, + and when the split landed this message did -- which is what + ``test_create_media_buy_request_requires_one_root_anyof_group`` caught. + + ``core/error.json`` requires every ``issues[]`` entry to carry a ``keyword`` + drawn from the JSON Schema vocabulary, and tells a consumer not to invent + one. A document that satisfies no branch fails each branch at FIELD + validation, so pydantic reports ``missing`` and nothing names the root's + combinator. ``missing`` is not in that vocabulary, so a consumer assembling + a conformant envelope has nothing valid to put in the field. + + A model validator on the shared base cannot supply it: field validation + fails first and an after-validator never runs. A wrapper around the UNION + runs, because it sits where the union's own validation happens. + + The absent field names travel with the keyword, in the message and in the + error context, so the refusal says more than a bare combinator keyword + says. The success path is untouched: a document that satisfies a branch + returns that branch's instance. + + A REFUSAL IS THE BRANCH'S OWN, NOT THE UNION'S. A pydantic union prefixes + every error's ``loc`` with the label of the choice that produced it: a plain + model's class name, and for a branch carrying a compiled validator + pydantic's own schema spelling, + ``function-after[_require_schema_required_group(), <Branch>]``. ``loc`` is + read as a path through the buyer's document -- ``core/error.json`` specifies + ``issues[].pointer`` as an RFC 6901 pointer into the request payload -- and + a choice label is a fact about this package's classes, not a key the buyer + sent. Measured: a union of two plain models reports + ``('PlainA', 'packages', 0, 'geo', 0)`` for a document error at + ``/packages/0/geo/0``, and the same union over the same models carrying a + model-level validator reports + ``('function-after[_require_schema_required_group(), WrappedA]', ...)``. So + the union is the element's source and the validator only changes its + spelling. + + So a refusal is restated as the refusal of ONE branch -- the one the + document meant -- with the label dropped. Nothing downstream has to + recognise a label, and the two spellings stop being a distinction anything + depends on. + + ``root_required`` NAMES THE FIELDS THE SCHEMA ROOT REQUIRES, and without it + this function cannot tell the two reasons a key is absent apart. A branch + reports ``missing`` at the root object for a key it SELECTS BY and for a key + the ROOT requires alike, and the second says the document is wrong at the + root rather than that no branch was elected: refusing it with the combinator + alone puts the only issue at the document root, so ``field`` translates to + the empty string and is omitted, while ``core/error.json`` says a seller + "MUST also populate field from issues[0]" whenever ``issues`` is present + (salesagent-8qela.116). + + It is a PARAMETER rather than something read off a branch because the + distinction is not derivable from the errors -- measured against each root's + pinned ``required`` list, "absent in every branch implies root-required" + agrees on 8 of 18 roots and fails in both directions. The generated base + declares it as ``_ROOT_REQUIRED`` and both the mixin and the emitted union + alias pass that one declaration; a hand-written wrapper passes nothing and + gets today's behaviour, exactly as it does for ``branches``. + """ + + def _wrap(value: Any, handler: Any) -> Any: + try: + return handler(value) + except ValidationError as exc: + # Group by branch: in a union's errors the first ``loc`` element + # names the branch that produced them. + per_branch: dict[str, list[dict[str, Any]]] = {} + for error in exc.errors(): + if not error["loc"]: + raise + per_branch.setdefault(str(error["loc"][0]), []).append(dict(error)) + if len(per_branch) == 1 and len(branches) > 1: + # DEGENERATE LABELS: the grouping above carries no information. + # It assumes pydantic gives each union choice a DISTINCT label, + # which is not a property anyone guarantees. A branch that gains + # a WRAP validator is spelled + # ``function-after[..., function-wrap[<validator>()]]`` -- a name + # with no class in it -- so every branch collapses to one label + # and one bucket. Measured: making the canonical layer's legacy + # refusal ``mode="wrap"`` did exactly that, all three branches of + # the create request arrived under one key, the bucket contained + # another branch's ``missing``, and the combinator refusal + # displaced the document error in 23 tests. + # + # WHICH SPELLINGS COLLAPSE, measured on the same two branches, so + # the next validator added to a branch can be checked against + # this rather than against the one case that bit us: + # + # plain branches ['First', 'Second'] distinct + # after-validator only ['function-after[f(), First]', + # 'function-after[f(), Second]'] + # distinct + # WRAP only ['function-wrap[f()]'] DEGENERATE + # after over wrap ['function-after[f(), + # function-wrap[f()]]'] DEGENERATE + # + # So the hazard is ANY wrap in a branch's schema, not one + # particular validator. + # + # So when the labels are degenerate, the branches are asked + # directly -- for SELECTION ONLY. The reported errors still come + # from the union, which is what keeps them in the caller's input + # mode; a branch's own errors are used to recognise WHICH of the + # merged errors are its. + selected = _errors_of_the_reached_branch( + value, branches, next(iter(per_branch.values())) + ) + if selected is not None: + per_branch = {"": selected} + # Report ONE branch's errors when the document satisfies that + # branch's requiredness. A branch that failed on fields IT requires + # was never reached; one that got past its own requiredness and + # failed on CONTENT is the branch the document meant, and that + # failure is what the caller needs -- replacing it with the + # combinator keyword would discard the only informative error, and + # merging it with the other branches' complaints would publish + # contradictory pointers, because branches differ by which fields + # they FORBID. + reached = [ + errors + for errors in per_branch.values() + if not any(_declines_the_document(error, label_depth=1) for error in errors) + ] + if reached: + # CEILING: the branches are pairwise disjoint, so at most one + # can get past its own requiredness. If two ever did, the first + # in the root's own branch order is reported. + raise ValidationError.from_exception_data( + exc.title, [_in_the_document(error) for error in reached[0]] + ) from exc + # One entry per absent root key, keeping the first branch's error + # for it: every branch reports the same ``missing`` for a key the + # ROOT requires, and the records are interchangeable. + at_the_root: dict[str, dict[str, Any]] = {} + for errors in per_branch.values(): + for entry in errors: + if _is_an_absent_root_key(entry): + at_the_root.setdefault(str(entry["loc"][1]), entry) + missing_root_keys = [ + entry for name, entry in sorted(at_the_root.items()) if name in root_required + ] + if missing_root_keys: + # THE DOCUMENT IS WRONG AT THE ROOT, which no branch can fix by + # being elected -- so this is not "no branch matched" and must + # not be reported as it. Those misses go out LOCATED, which is + # what gives ``issues[0]`` a pointer and ``field`` a value, with + # the combinator beside them so the keyword is not lost: the + # jsonschema oracle reports the same two things for the same + # document, and the two lanes disagreeing about WHERE a document + # is wrong is the defect (salesagent-8qela.116). + raise ValidationError.from_exception_data( + exc.title, + [_in_the_document(error) for error in missing_root_keys] + + [ + _no_branch_matched( + keyword, + branches, + [name for name in sorted(at_the_root) if name not in root_required], + ) + ], + ) from exc + absent = sorted(at_the_root) + if not absent: + # NO branch was reached and none of them named an absent field, so + # there is no field group to report -- every branch declined for a + # key the document CARRIES. That is the forbidden-key refusal, and + # it is the informative one, so it is reported rather than replaced. + # + # Restated through ``_in_the_document`` rather than re-raised bare. + # A bare ``raise`` republishes the union's own errors, whose ``loc`` + # still carries pydantic's choice label -- measured on the create + # request's both-keys document: + # ``loc=('function-after[_require_schema_required_group(), + # function-wrap[_reject_legacy_creative_identity()]]',)``. That + # label is a fact about this package's schema spelling, not a key + # the buyer sent, and ``core/error.json`` specifies + # ``issues[].pointer`` as a pointer into the request payload + # (salesagent-8qela.28). Before the reached-filter was corrected + # this path was unreachable for that document -- the bucket read as + # REACHED and went out label-stripped -- so fixing the filter is + # what exposed the bare raise. + raise ValidationError.from_exception_data( + exc.title, + [_in_the_document(error) for errors in per_branch.values() for error in errors], + ) from exc + raise _no_branch_matched(keyword, branches, absent)["type"] from exc + + return _wrap + + +def _no_branch_matched( + keyword: str, branches: Sequence[type], absent: Sequence[str] +) -> dict[str, Any]: + """The combinator refusal, as an error detail. + + A detail rather than a bare ``PydanticCustomError`` because it is raised on + its own AND appended beside the located root-required misses, and writing + the sentence twice is how the two spellings would come to differ. + + ``absent`` is the BRANCH-SELECTING keys only when a root-required key is + also absent, because a reader shown "requires at least one of these field + groups" alongside a list mixing the two cannot tell which keys would have + elected a branch. That mixing is what the refusal published before: + ``fields: account, brand, budget_allocation, end_time, idempotency_key, + start_time, total_budget`` for the create request, five root-required and + two branch-required in one list. + """ + groups = " | ".join(branch_title(branch) for branch in branches) + names = ", ".join(absent) + return { + "type": PydanticCustomError( + keyword, + "requires at least one of these field groups: {groups}", + # The absent field names travel beside the titles in the error + # context, so a consumer can report which keys were missing + # without parsing the sentence. + {"groups": groups or names, "fields": names}, + ), + "loc": (), + "input": None, + } diff --git a/src/adcp/types/_eager.py b/src/adcp/types/_eager.py index fdd27d206..0ceea07b4 100644 --- a/src/adcp/types/_eager.py +++ b/src/adcp/types/_eager.py @@ -1,10 +1,13 @@ """Eager realization of the AdCP type surface. -This module binds every public name exported by :mod:`adcp.types` and runs -the import-time Pydantic patching (``_ergonomic`` coercion + -``_forward_compat`` open-union ``Format.assets``). It is imported on demand -by :mod:`adcp.types`'s lazy ``__getattr__`` the first time any type symbol is -accessed, so ``import adcp.types`` itself stays cheap. +This module binds every public name exported by :mod:`adcp.types`. It is +imported on demand by :mod:`adcp.types`'s lazy ``__getattr__`` the first time +any type symbol is accessed, so ``import adcp.types`` itself stays cheap. + +It used to also run the import-time Pydantic patching -- ``_ergonomic`` +coercion and ``_forward_compat``'s open ``Format.assets``. Both are now +declared where the field is declared, by the generator, so there is nothing +to run here. Do not import this module directly — import from :mod:`adcp.types` (or :mod:`adcp`). The name set here is mirrored by ``adcp.types.__all__``; the @@ -13,20 +16,12 @@ from __future__ import annotations -import importlib - -# Run the import-time patchers as a side effect, before binding the generated -# types below (must come first — see the original "must be imported before -# other types" note): ``_ergonomic`` applies BeforeValidator coercion and -# ``_forward_compat`` opens ``Format.assets`` / ``RepeatableAssetGroup.assets``. -# Imported via ``import_module`` — a call, not a name binding — so they read as -# the intentional side-effect imports they are rather than tripping -# unused-import analysers. (The removed ``format_category`` submodule has a real -# shim at ``domains/enums/format_category.py``, restored after codegen by +# ``_forward_compat`` is now six name bindings, imported below for them. The +# ``import_module`` call that ran it for its side effects is gone with the side +# effects: the coercion and the open unions are generated into the fields. +# (The removed ``format_category`` submodule has a real shim at +# ``domains/enums/format_category.py``, restored after codegen by # ``scripts/post_generate_fixes.py``.) -importlib.import_module("adcp.types._ergonomic") -importlib.import_module("adcp.types._forward_compat") - # Re-exported submodules (part of the public surface via ``__all__`` below). from adcp.types import _generated as generated # noqa: F401 from adcp.types import aliases # noqa: F401 @@ -927,6 +922,35 @@ CreativeAsset = _canonical_creative.CreativeAsset CreativeFilters = _canonical_creative.CreativeFilters CreativeManifest = _canonical_creative.CreativeManifest +# The named-format arm has no canonical counterpart: the canonical layer models +# the canonical-format arm only, so the deprecated format_id path reaches the +# other arm through the generated class. +CreativeManifestNamedFormatManifest = generated.CreativeManifestNamedFormatManifest + +# The shared shape of each root whose arms partition AND whose union is public. +# PUBLIC because the arms are one type only if that type can be named: a method +# behaving per arm has to take something, and an isinstance check has to test +# something. The union keeps the root's own name and means "one of these"; the +# shape means "any of them". Spelled Shape rather than Base because +# versioned_bases already exports 197 public <Tool><Direction>Base names meaning +# something else -- the extension base for a tool's request at a pinned version. +# +# A union's visibility FOLLOWS its root's. The BASE takes the root's own name +# and is already exported under it, so only the union needs a name here -- for +# a caller that wants a type checker to force exhaustive narrowing rather than +# the runtime strictness the base already gives. +# The CANONICAL union, because adcp.types.CreateMediaBuyRequest is the canonical +# base. Exporting the generated union beside a canonical base would pair a name +# with a union whose members do not subclass it. +CreateMediaBuyRequestBranches = _canonical_creative.CreateMediaBuyRequestBranches +VerifyBrandClaimRequestBranches = generated.VerifyBrandClaimRequestBranches +# CreativeAssetBranches is deliberately NOT exported. adcp.types.CreativeAsset is +# the canonical model refining ONE branch, not a base over both, so pairing the +# union with that name would promise a supertype it is not. The generated union +# is already public as LegacyCreativeAsset. +ListAccountChangesResponseBranches = generated.ListAccountChangesResponseBranches +RequestProposalsRequestBranches = generated.RequestProposalsRequestBranches +WebhookChallengeResponseBranches = generated.WebhookChallengeResponseBranches CreativeVariant = _canonical_creative.CreativeVariant Format = _canonical_creative.Format GetCreativeDeliveryResponse = _canonical_creative.GetCreativeDeliveryResponse diff --git a/src/adcp/types/_ergonomic.py b/src/adcp/types/_ergonomic.py deleted file mode 100644 index 76c054cb4..000000000 --- a/src/adcp/types/_ergonomic.py +++ /dev/null @@ -1,701 +0,0 @@ -# AUTO-GENERATED by scripts/generate_ergonomic_coercion.py -# Do not edit manually - changes will be overwritten on next type generation. -# To regenerate: python scripts/generate_types.py - -# ruff: noqa: E501, I001 -"""Apply type coercion to generated types for better ergonomics. - -This module patches the generated types to accept more flexible input types -while maintaining type safety. It uses Pydantic's model_rebuild() to add -BeforeValidator annotations to fields. - -Why import-time patching? - We apply coercion at module load time rather than lazily because: - 1. Pydantic validation runs during __init__, before any lazy access - 2. model_rebuild() is the standard Pydantic pattern for post-hoc changes - 3. The cost is minimal (~10-20ms for all types, once at import) - 4. After import, there is zero runtime overhead - 5. This approach maintains full type checker compatibility - -Coercion rules applied: -1. Enum fields accept string values (e.g., "video" for enum fields) -2. List[Enum] fields accept list of strings (e.g., ["image", "video"]) -3. ContextObject fields accept dict values -4. ExtensionObject fields accept dict values -5. FieldModel (enum) lists accept string lists - -Note: List variance issues (list[Subclass] not assignable to list[BaseClass]) -are a fundamental Python typing limitation. Response-only container fields -(affected_packages, media_buys, packages, media_buy_deliveries) already use -Sequence[T] in their generated base class. For other fields not yet migrated, -adopters should use Sequence[T] in their own code or cast() for appeasement. -""" - -from __future__ import annotations - -from collections.abc import Sequence -from typing import Annotated, Any - -from pydantic import BeforeValidator, WrapValidator - -from adcp.types.coercion import ( - coerce_subclass_list, - coerce_to_enum, - coerce_to_enum_list, - coerce_to_unique_enum_list, - coerce_to_model, -) - -from adcp.types.domains.core.context import ContextObject -from adcp.types.domains.core.creative_asset import CreativeAsset -from adcp.types.domains.core.creative_assignment import CreativeAssignment -from adcp.types.domains.core.error import Error -from adcp.types.domains.core.ext import ExtensionObject -from adcp.types.domains.core.format import Format -from adcp.types.domains.core.package import Package -from adcp.types.domains.core.product import Product -from adcp.types.domains.enums.advertiser_industry import AdvertiserIndustry -from adcp.types.domains.enums.asset_content_type import AssetContentType -from adcp.types.domains.enums.creative_sort_field import CreativeSortField -from adcp.types.domains.enums.delivery_type import DeliveryType -from adcp.types.domains.enums.disclosure_persistence import DisclosurePersistence -from adcp.types.domains.enums.disclosure_position import DisclosurePosition -from adcp.types.domains.enums.media_buy_status import MediaBuyStatus -from adcp.types.domains.enums.media_buy_valid_action import MediaBuyValidAction -from adcp.types.domains.enums.pacing import Pacing -from adcp.types.domains.enums.sort_direction import SortDirection -from adcp.types.domains.enums.task_status import TaskStatus -from adcp.types.domains.enums.wcag_level import WcagLevel -from adcp.types.domains.media_buy.create_media_buy_request import ( - CreateMediaBuyRequest, -) -from adcp.types.domains.media_buy.get_products_request import ( - GetProductsRequest, -) -from adcp.types.domains.media_buy.list_creative_formats_request import ( - ListCreativeFormatsRequest, -) -from adcp.types.domains.creative.list_creative_formats_request import ( - ListCreativeFormatsRequestCreativeAgent, -) -from adcp.types.domains.creative.list_creatives_request import ( - Field1 as ListCreativesField, - ListCreativesRequest, - Sort, -) -from adcp.types.domains.media_buy.package_request import PackageRequest -from adcp.types.domains.media_buy.package_update import PackageUpdate -from adcp.types.domains.media_buy.create_media_buy_response import ( - CreateMediaBuyResponse1, -) -from adcp.types.domains.media_buy.update_media_buy_response import ( - UpdateMediaBuyResponse1, -) -from adcp.types.domains.media_buy.get_media_buy_delivery_response import ( - GetMediaBuyDeliveryResponse, - MediaBuyDelivery, - NotificationType, -) -from adcp.types.domains.media_buy.get_products_response import GetProductsResponse -from adcp.types.domains.media_buy.list_creative_formats_response import ( - CreativeAgent, - ListCreativeFormatsResponse, -) -from adcp.types.domains.creative.list_creatives_response import ( - ListCreativesResponse, -) -from adcp.types.domains.creative.list_creatives_request import AssignmentProjection -from adcp.types.domains.media_buy.get_products_request import BuyingMode -from adcp.types.domains.media_buy.get_products_response import CacheScope -from adcp.types.domains.creative.list_creatives_response import Creative -from adcp.types.domains.media_buy.list_creative_formats_response import Source -from adcp.types.domains.creative.list_creative_formats_request import Type - - -def _apply_coercion() -> None: - """Apply coercion validators to generated types. - - This function modifies the generated types in-place to accept - more flexible input types. - """ - # Apply coercion to ListCreativeFormatsRequest - # - asset_types: list[AssetContentType | str] | None - # - wcag_level: WcagLevel | str | None - # - disclosure_positions: list[DisclosurePosition | str] | None - # - disclosure_persistence: list[DisclosurePersistence | str] | None - # - context: ContextObject | dict | None - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - ListCreativeFormatsRequest, - "asset_types", - Annotated[ - list[AssetContentType] | None, - BeforeValidator(coerce_to_enum_list(AssetContentType)), - ], - ) - _patch_field_annotation( - ListCreativeFormatsRequest, - "wcag_level", - Annotated[WcagLevel | None, BeforeValidator(coerce_to_enum(WcagLevel))], - ) - _patch_unique_enum_list( - ListCreativeFormatsRequest, - "disclosure_positions", - DisclosurePosition, - ) - _patch_unique_enum_list( - ListCreativeFormatsRequest, - "disclosure_persistence", - DisclosurePersistence, - ) - _patch_field_annotation( - ListCreativeFormatsRequest, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - ListCreativeFormatsRequest, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - ListCreativeFormatsRequest.model_rebuild(force=True) - - # Apply coercion to ListCreativeFormatsRequestCreativeAgent - # - type: Type | str | None - # - asset_types: list[AssetContentType | str] | None - # - wcag_level: WcagLevel | str | None - # - disclosure_positions: list[DisclosurePosition | str] | None - # - disclosure_persistence: list[DisclosurePersistence | str] | None - # - context: ContextObject | dict | None - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - ListCreativeFormatsRequestCreativeAgent, - "type", - Annotated[Type | None, BeforeValidator(coerce_to_enum(Type))], - ) - _patch_field_annotation( - ListCreativeFormatsRequestCreativeAgent, - "asset_types", - Annotated[ - list[AssetContentType] | None, - BeforeValidator(coerce_to_enum_list(AssetContentType)), - ], - ) - _patch_field_annotation( - ListCreativeFormatsRequestCreativeAgent, - "wcag_level", - Annotated[WcagLevel | None, BeforeValidator(coerce_to_enum(WcagLevel))], - ) - _patch_unique_enum_list( - ListCreativeFormatsRequestCreativeAgent, - "disclosure_positions", - DisclosurePosition, - ) - _patch_unique_enum_list( - ListCreativeFormatsRequestCreativeAgent, - "disclosure_persistence", - DisclosurePersistence, - ) - _patch_field_annotation( - ListCreativeFormatsRequestCreativeAgent, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - ListCreativeFormatsRequestCreativeAgent, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - ListCreativeFormatsRequestCreativeAgent.model_rebuild(force=True) - - # Apply coercion to ListCreativesRequest - # - assignment_projection: AssignmentProjection | str | None - # - fields: list[ListCreativesField | str] | None - # - context: ContextObject | dict | None - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - ListCreativesRequest, - "assignment_projection", - Annotated[ - AssignmentProjection | None, BeforeValidator(coerce_to_enum(AssignmentProjection)) - ], - ) - _patch_field_annotation( - ListCreativesRequest, - "fields", - Annotated[ - list[ListCreativesField] | None, - BeforeValidator(coerce_to_enum_list(ListCreativesField)), - ], - ) - _patch_field_annotation( - ListCreativesRequest, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - ListCreativesRequest, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - ListCreativesRequest.model_rebuild(force=True) - - # Apply coercion to Sort - # - field: CreativeSortField | str | None - # - direction: SortDirection | str | None - _patch_field_annotation( - Sort, - "field", - Annotated[CreativeSortField | None, BeforeValidator(coerce_to_enum(CreativeSortField))], - ) - _patch_field_annotation( - Sort, - "direction", - Annotated[SortDirection | None, BeforeValidator(coerce_to_enum(SortDirection))], - ) - Sort.model_rebuild(force=True) - - # Apply coercion to GetProductsRequest - # - buying_mode: BuyingMode | str | None - # - preferred_delivery_types: list[DeliveryType | str] | None - # - context: ContextObject | dict | None - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - GetProductsRequest, - "buying_mode", - Annotated[BuyingMode | None, BeforeValidator(coerce_to_enum(BuyingMode))], - ) - _patch_field_annotation( - GetProductsRequest, - "preferred_delivery_types", - Annotated[ - list[DeliveryType] | None, - BeforeValidator(coerce_to_enum_list(DeliveryType)), - ], - ) - _patch_field_annotation( - GetProductsRequest, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - GetProductsRequest, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - GetProductsRequest.model_rebuild(force=True) - - # Apply coercion to PackageRequest - # - pacing: Pacing | str | None - # - creative_assignments: list[CreativeAssignment] (accepts subclass instances) - # - creatives: Sequence[CreativeAsset] (accepts subclass instances) - # - context: ContextObject | dict | None - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - PackageRequest, - "pacing", - Annotated[Pacing | None, BeforeValidator(coerce_to_enum(Pacing))], - ) - _patch_field_annotation( - PackageRequest, - "creative_assignments", - Annotated[ - list[CreativeAssignment] | None, - BeforeValidator(coerce_subclass_list(CreativeAssignment)), - ], - ) - _patch_field_annotation( - PackageRequest, - "creatives", - Annotated[ - Sequence[CreativeAsset] | None, - BeforeValidator(coerce_subclass_list(CreativeAsset)), - ], - ) - _patch_field_annotation( - PackageRequest, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - PackageRequest, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - PackageRequest.model_rebuild(force=True) - - # Apply coercion to CreateMediaBuyRequest - # - packages: Sequence[PackageRequest] (accepts subclass instances) - # - advertiser_industry: AdvertiserIndustry | str | None - # - pacing: Pacing | str | None - # - context: ContextObject | dict | None - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - CreateMediaBuyRequest, - "packages", - Annotated[ - Sequence[PackageRequest] | None, - BeforeValidator(coerce_subclass_list(PackageRequest)), - ], - ) - _patch_field_annotation( - CreateMediaBuyRequest, - "advertiser_industry", - Annotated[AdvertiserIndustry | None, BeforeValidator(coerce_to_enum(AdvertiserIndustry))], - ) - _patch_field_annotation( - CreateMediaBuyRequest, - "pacing", - Annotated[Pacing | None, BeforeValidator(coerce_to_enum(Pacing))], - ) - _patch_field_annotation( - CreateMediaBuyRequest, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - CreateMediaBuyRequest, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - CreateMediaBuyRequest.model_rebuild(force=True) - - # Apply coercion to PackageUpdate - # - pacing: Pacing | str | None - # - creative_assignments: list[CreativeAssignment] (accepts subclass instances) - # - creatives: list[CreativeAsset] (accepts subclass instances) - # - context: ContextObject | dict | None - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - PackageUpdate, - "pacing", - Annotated[Pacing | None, BeforeValidator(coerce_to_enum(Pacing))], - ) - _patch_field_annotation( - PackageUpdate, - "creative_assignments", - Annotated[ - list[CreativeAssignment] | None, - BeforeValidator(coerce_subclass_list(CreativeAssignment)), - ], - ) - _patch_field_annotation( - PackageUpdate, - "creatives", - Annotated[ - list[CreativeAsset] | None, - BeforeValidator(coerce_subclass_list(CreativeAsset)), - ], - ) - _patch_field_annotation( - PackageUpdate, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - PackageUpdate, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - PackageUpdate.model_rebuild(force=True) - - # Apply coercion to GetProductsResponse - # - context: ContextObject | dict | None - # - status: TaskStatus | str | None - # - products: list[Product] (accepts subclass instances) - # - errors: list[Error] (accepts subclass instances) - # - cache_scope: CacheScope | str | None - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - GetProductsResponse, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - GetProductsResponse, - "status", - Annotated[TaskStatus | None, BeforeValidator(coerce_to_enum(TaskStatus))], - ) - _patch_field_annotation( - GetProductsResponse, - "products", - Annotated[ - list[Product] | None, - BeforeValidator(coerce_subclass_list(Product)), - ], - ) - _patch_field_annotation( - GetProductsResponse, - "errors", - Annotated[ - list[Error] | None, - BeforeValidator(coerce_subclass_list(Error)), - ], - ) - _patch_field_annotation( - GetProductsResponse, - "cache_scope", - Annotated[CacheScope | None, BeforeValidator(coerce_to_enum(CacheScope))], - ) - _patch_field_annotation( - GetProductsResponse, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - GetProductsResponse.model_rebuild(force=True) - - # Apply coercion to ListCreativesResponse - # - context: ContextObject | dict | None - # - status: TaskStatus | str | None - # - creatives: Sequence[Creative] (accepts subclass instances) - # - errors: list[Error] (accepts subclass instances) - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - ListCreativesResponse, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - ListCreativesResponse, - "status", - Annotated[TaskStatus | None, BeforeValidator(coerce_to_enum(TaskStatus))], - ) - _patch_field_annotation( - ListCreativesResponse, - "creatives", - Annotated[ - Sequence[Creative], - BeforeValidator(coerce_subclass_list(Creative)), - ], - ) - _patch_field_annotation( - ListCreativesResponse, - "errors", - Annotated[ - list[Error] | None, - BeforeValidator(coerce_subclass_list(Error)), - ], - ) - _patch_field_annotation( - ListCreativesResponse, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - ListCreativesResponse.model_rebuild(force=True) - - # Apply coercion to ListCreativeFormatsResponse - # - context: ContextObject | dict | None - # - status: TaskStatus | str | None - # - formats: list[Format] (accepts subclass instances) - # - source: Source | str | None - # - creative_agents: list[CreativeAgent] (accepts subclass instances) - # - errors: list[Error] (accepts subclass instances) - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - ListCreativeFormatsResponse, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - ListCreativeFormatsResponse, - "status", - Annotated[TaskStatus | None, BeforeValidator(coerce_to_enum(TaskStatus))], - ) - _patch_field_annotation( - ListCreativeFormatsResponse, - "formats", - Annotated[ - list[Format], - BeforeValidator(coerce_subclass_list(Format)), - ], - ) - _patch_field_annotation( - ListCreativeFormatsResponse, - "source", - Annotated[Source | None, BeforeValidator(coerce_to_enum(Source))], - ) - _patch_field_annotation( - ListCreativeFormatsResponse, - "creative_agents", - Annotated[ - list[CreativeAgent] | None, - BeforeValidator(coerce_subclass_list(CreativeAgent)), - ], - ) - _patch_field_annotation( - ListCreativeFormatsResponse, - "errors", - Annotated[ - list[Error] | None, - BeforeValidator(coerce_subclass_list(Error)), - ], - ) - _patch_field_annotation( - ListCreativeFormatsResponse, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - ListCreativeFormatsResponse.model_rebuild(force=True) - - # Apply coercion to CreateMediaBuyResponse1 - # - context: ContextObject | dict | None - # - media_buy_status: MediaBuyStatus | str | None - # - pacing: Pacing | str | None - # - valid_actions: list[MediaBuyValidAction | str] | None - # - packages: list[Package] (accepts subclass instances) - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - CreateMediaBuyResponse1, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - CreateMediaBuyResponse1, - "media_buy_status", - Annotated[MediaBuyStatus | None, BeforeValidator(coerce_to_enum(MediaBuyStatus))], - ) - _patch_field_annotation( - CreateMediaBuyResponse1, - "pacing", - Annotated[Pacing | None, BeforeValidator(coerce_to_enum(Pacing))], - ) - _patch_field_annotation( - CreateMediaBuyResponse1, - "valid_actions", - Annotated[ - list[MediaBuyValidAction] | None, - BeforeValidator(coerce_to_enum_list(MediaBuyValidAction)), - ], - ) - _patch_field_annotation( - CreateMediaBuyResponse1, - "packages", - Annotated[ - list[Package], - BeforeValidator(coerce_subclass_list(Package)), - ], - ) - _patch_field_annotation( - CreateMediaBuyResponse1, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - CreateMediaBuyResponse1.model_rebuild(force=True) - - # Apply coercion to UpdateMediaBuyResponse1 - # - context: ContextObject | dict | None - # - media_buy_status: MediaBuyStatus | str | None - # - pacing: Pacing | str | None - # - affected_packages: Sequence[Package] (accepts subclass instances) - # - valid_actions: list[MediaBuyValidAction | str] | None - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - UpdateMediaBuyResponse1, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - UpdateMediaBuyResponse1, - "media_buy_status", - Annotated[MediaBuyStatus | None, BeforeValidator(coerce_to_enum(MediaBuyStatus))], - ) - _patch_field_annotation( - UpdateMediaBuyResponse1, - "pacing", - Annotated[Pacing | None, BeforeValidator(coerce_to_enum(Pacing))], - ) - _patch_field_annotation( - UpdateMediaBuyResponse1, - "affected_packages", - Annotated[ - Sequence[Package] | None, - BeforeValidator(coerce_subclass_list(Package)), - ], - ) - _patch_field_annotation( - UpdateMediaBuyResponse1, - "valid_actions", - Annotated[ - list[MediaBuyValidAction] | None, - BeforeValidator(coerce_to_enum_list(MediaBuyValidAction)), - ], - ) - _patch_field_annotation( - UpdateMediaBuyResponse1, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - UpdateMediaBuyResponse1.model_rebuild(force=True) - - # Apply coercion to GetMediaBuyDeliveryResponse - # - context: ContextObject | dict | None - # - status: TaskStatus | str | None - # - notification_type: NotificationType | str | None - # - media_buy_deliveries: Sequence[MediaBuyDelivery] (accepts subclass instances) - # - errors: list[Error] (accepts subclass instances) - # - ext: ExtensionObject | dict | None - _patch_field_annotation( - GetMediaBuyDeliveryResponse, - "context", - Annotated[ContextObject | None, BeforeValidator(coerce_to_model(ContextObject))], - ) - _patch_field_annotation( - GetMediaBuyDeliveryResponse, - "status", - Annotated[TaskStatus | None, BeforeValidator(coerce_to_enum(TaskStatus))], - ) - _patch_field_annotation( - GetMediaBuyDeliveryResponse, - "notification_type", - Annotated[NotificationType | None, BeforeValidator(coerce_to_enum(NotificationType))], - ) - _patch_field_annotation( - GetMediaBuyDeliveryResponse, - "media_buy_deliveries", - Annotated[ - Sequence[MediaBuyDelivery], - BeforeValidator(coerce_subclass_list(MediaBuyDelivery)), - ], - ) - _patch_field_annotation( - GetMediaBuyDeliveryResponse, - "errors", - Annotated[ - list[Error] | None, - BeforeValidator(coerce_subclass_list(Error)), - ], - ) - _patch_field_annotation( - GetMediaBuyDeliveryResponse, - "ext", - Annotated[ExtensionObject | None, BeforeValidator(coerce_to_model(ExtensionObject))], - ) - GetMediaBuyDeliveryResponse.model_rebuild(force=True) - - -def _patch_unique_enum_list( - model: Any, - field_name: str, - enum_class: type, -) -> None: - """Add an order-preserving unique-items enum validator.""" - model.model_fields[field_name].metadata.append( - WrapValidator(coerce_to_unique_enum_list(enum_class)) - ) - - -def _patch_field_annotation( - model: type, - field_name: str, - new_annotation: Any, -) -> None: - """Patch a field annotation on a Pydantic model. - - This modifies the model's __annotations__ dict to add - BeforeValidator coercion. - """ - model.__annotations__[field_name] = new_annotation - - -# Apply coercion when module is imported -_apply_coercion() diff --git a/src/adcp/types/_forward_compat.py b/src/adcp/types/_forward_compat.py index 092635350..dc2098035 100644 --- a/src/adcp/types/_forward_compat.py +++ b/src/adcp/types/_forward_compat.py @@ -1,563 +1,45 @@ -"""Forward-compatibility and generated-model composition patches. - -Patches Format.assets and RepeatableAssetGroup.assets at import -time so responses containing novel asset_type values (e.g., 'pixel_tracker') -parse as UnknownFormatAsset / UnknownGroupAsset instead of raising a cascade -of ValidationErrors that zero out the entire list_creative_formats response. - -This module is intentionally NOT auto-generated. It lives outside -the generated tree and is preserved across codegen runs (generate_types.py only -wipes src/adcp/types/domains/). - -It also replaces identity-distinct bundled clones at public capability -boundaries with their canonical public model classes. This keeps independently -generated views of the same wire schema composable as typed Python objects. - -The rc.3 targeting mutation fields retain TargetingOverlayInput for raw data -while accepting beta.14 TargetingOverlay instances as a compatibility bridge. - -Import order: comes after _ergonomic in types/__init__.py (alphabetical by -underscore-preserved sort). Importing this module directly triggers import of -adcp.types.aliases as a side effect (via ``from adcp.types.aliases import …``) -so there is no circular-import risk and no strict ordering requirement against -aliases in __init__.py. - -Import layering: this module imports directly from the generated tree (like -aliases.py and _ergonomic.py) because it must patch the generated classes -in-place. It is therefore listed in ALLOWED_FILES in test_import_layering.py. +"""Six request names, bound to the class that serves each one. + +This module used to patch the generated classes at import time: it opened the +asset unions, threaded the read-back nodes through the response classes, +replaced the bundled capability clones, and bridged the targeting overlay. Every +one of those is now DECLARED where the field is declared -- ``PIN_GAP_FIELDS`` +in ``scripts/generate_types.py`` for a field the pin does not have, and +``COMPATIBILITY_ANNOTATIONS`` / ``READBACK_CLONES`` / ``_OPEN_ASSET_UNIONS`` in +``scripts/post_generate_fixes.py`` for a field whose type the implementation +replaces. A patch reached the class it named; a declaration reaches every class +that nests it, which is what a parsed document actually goes through. + +What is left is name binding. Four of these are the generated class under its +public name, and two are the canonical class rather than the generated one -- +which is a fact about which class serves the name, not a patch. """ from __future__ import annotations -import json -from collections.abc import Callable -from copy import copy, deepcopy -from functools import partial -from types import GenericAlias -from typing import TYPE_CHECKING, Annotated, Any, cast, get_args - -from pydantic import ( - BaseModel, - ConfigDict, - Field, - GetCoreSchemaHandler, - GetPydanticSchema, - SerializerFunctionWrapHandler, - ValidationError, - ValidatorFunctionWrapHandler, - create_model, - model_validator, -) -from pydantic.fields import FieldInfo -from pydantic.json_schema import SkipJsonSchema -from pydantic_core import CoreSchema, InitErrorDetails, core_schema - -from adcp.types.aliases import FormatAssetUnion, GroupFormatAssetUnion, RepeatableAssetGroup -from adcp.types.base import AdCPBaseModel from adcp.types.canonical_creative import CreateMediaBuyRequest as _PublicCreateMediaBuyRequest -from adcp.types.canonical_creative import PackageRequest as PublicPackageRequest -from adcp.types.canonical_creative import PackageUpdate as PublicPackageUpdate from adcp.types.canonical_creative import UpdateMediaBuyRequest as _PublicUpdateMediaBuyRequest -from adcp.types.domains.bundled.protocol.get_adcp_capabilities_response import ( - AcceptancePolicyDiscovery as BundledAcceptancePolicyDiscovery, -) -from adcp.types.domains.bundled.protocol.get_adcp_capabilities_response import ( - MediaBuy as BundledCapabilitiesMediaBuy, -) -from adcp.types.domains.bundled.protocol.get_adcp_capabilities_response import ( - Portfolio as BundledCapabilitiesPortfolio, -) -from adcp.types.domains.bundled.protocol.get_adcp_capabilities_response import ( - PrimaryCountry as BundledPrimaryCountry, -) -from adcp.types.domains.bundled.protocol.get_adcp_capabilities_response import ( - PublisherDomain as BundledPublisherDomain, -) -from adcp.types.domains.core.canonical_product import PublisherDomain -from adcp.types.domains.core.creative_manifest import CreativeManifest -from adcp.types.domains.core.creative_variant import CreativeVariant -from adcp.types.domains.core.format import Format -from adcp.types.domains.core.mcp_webhook_payload import McpWebhookPayload -from adcp.types.domains.core.media_buy_features import MediaBuyFeatures from adcp.types.domains.core.reporting_webhook import ( ReportingWebhook as _GeneratedReportingWebhook, ) -from adcp.types.domains.core.targeting import TargetingOverlay -from adcp.types.domains.core.targeting_input import TargetingOverlayInput -from adcp.types.domains.core.version_envelope import AdcpVersionEnvelope -from adcp.types.domains.creative.get_creative_delivery_response import ( - Creative as DeliveryCreative, -) -from adcp.types.domains.creative.get_creative_delivery_response import ( - GetCreativeDeliveryResponse, -) -from adcp.types.domains.creative.preview_creative_response import PreviewCreativeResponse3 from adcp.types.domains.media_buy.accept_proposal_request import ( AcceptProposalRequest as _GeneratedAcceptProposalRequest, ) -from adcp.types.domains.media_buy.build_creative_response import ( - BuildCreativeResponse1, - BuildCreativeResponse3, - BuildCreativeResponse4, -) -from adcp.types.domains.media_buy.build_creative_response import ( - Creative as BuildCreative, -) -from adcp.types.domains.media_buy.build_creative_response import ( - Variant as BuildCreativeVariant, -) from adcp.types.domains.media_buy.buy_products_request import ( BuyProductsRequest as _GeneratedBuyProductsRequest, ) from adcp.types.domains.media_buy.control_media_buy_request import ( ControlMediaBuyRequest as _GeneratedControlMediaBuyRequest, ) -from adcp.types.domains.media_buy.create_media_buy_request import ( - CreateMediaBuyRequest as _GeneratedCreateMediaBuyRequest, -) -from adcp.types.domains.media_buy.package_control import PackageControl -from adcp.types.domains.media_buy.package_request import PackageRequest -from adcp.types.domains.media_buy.package_update import PackageUpdate -from adcp.types.domains.media_buy.product_purchase_input import ProductPurchaseInput -from adcp.types.domains.media_buy.update_media_buy_request import ( - UpdateMediaBuyRequest as _GeneratedUpdateMediaBuyRequest, -) -from adcp.types.domains.protocol.get_adcp_capabilities_response import ( - AcceptancePolicyDiscovery, - PrimaryCountry, -) -from adcp.types.domains.trusted_match.context_match_response import ( - ContextMatchResponseRouterPublisher, -) -from adcp.types.domains.trusted_match.offer import Offer -from adcp.types.domains.trusted_match.provider_context_match_response import ( - ContextMatchResponseProviderRouter, -) - -_ReportingOperationId = Annotated[ - str | None, - Field( - min_length=1, - max_length=255, - pattern="^[A-Za-z0-9_.:-]{1,255}$", - description=( - "Opt-in AdCP 3.2 reporting-stream correlation extension pending adcp#7885. " - "Cooperating sellers store and echo the buyer-supplied value verbatim in " - "media_buy_delivery webhook payloads. Confirm seller support before use; " - "registrations without a value cannot enable this interim delivery path." - ), - ), -] - -if TYPE_CHECKING: - - class ReportingWebhook(_GeneratedReportingWebhook): - """Static view of the optional field patched into the original runtime class.""" - - operation_id: _ReportingOperationId = None - - class AcceptProposalRequest(_GeneratedAcceptProposalRequest): - reporting_webhook: ReportingWebhook | None = None - - class BuyProductsRequest(_GeneratedBuyProductsRequest): - reporting_webhook: ReportingWebhook | None = None - - class ControlMediaBuyRequest(_GeneratedControlMediaBuyRequest): - reporting_webhook: ReportingWebhook | None = None - - class CreateMediaBuyRequest(_PublicCreateMediaBuyRequest): - reporting_webhook: ReportingWebhook | None = None - - class UpdateMediaBuyRequest(_PublicUpdateMediaBuyRequest): - reporting_webhook: ReportingWebhook | None = None - -else: - # Keep class identity across public imports and nested generated requests. - ReportingWebhook = _GeneratedReportingWebhook - AcceptProposalRequest = _GeneratedAcceptProposalRequest - BuyProductsRequest = _GeneratedBuyProductsRequest - ControlMediaBuyRequest = _GeneratedControlMediaBuyRequest - CreateMediaBuyRequest = _PublicCreateMediaBuyRequest - UpdateMediaBuyRequest = _PublicUpdateMediaBuyRequest - - -class _ManifestReadbackModel(AdCPBaseModel): - """Independent from strict inputs: tolerant instances must not validate as them.""" - - model_config = ConfigDict(extra="allow") - - @model_validator(mode="before") - @classmethod - def _normalize_readback(cls, data: Any) -> Any: - if isinstance(data, AdCPBaseModel) and not isinstance(data, cls): - return data.model_dump(mode="python") - return data - - -class _VersionedManifestReadbackModel(AdcpVersionEnvelope, _ManifestReadbackModel): - """Keep the shared version envelope on build response nodes.""" - - -def _manifest_readback_clone( - name: str, - source: type[AdCPBaseModel], - overrides: dict[str, Any], - *, - validators: dict[str, Any] | None = None, -) -> type[AdCPBaseModel]: - # Copy all field constraints without inheriting the strict source model. - # A tolerant subclass would pass its parent's default instance validation. - fields: dict[str, Any] = { - key: (overrides.get(key, field.annotation), deepcopy(field)) - for key, field in source.model_fields.items() - } - return create_model( - name, - __base__=( - _VersionedManifestReadbackModel - if issubclass(source, AdcpVersionEnvelope) - else _ManifestReadbackModel - ), - __module__=__name__, - __validators__=validators, - **fields, - ) - - -def _normalize_readback_manifest(data: Any) -> Any: - # Pydantic binds the generated validator proxy to a callable at runtime. - normalize = cast(Callable[[Any], Any], CreativeManifest._coerce_standalone_assets) - return normalize(data) - - -_ReadbackCreativeManifest = _manifest_readback_clone( - "_ReadbackCreativeManifest", - CreativeManifest, - # No field override: ``format_kind`` is generated as ``str`` (see - # OPEN_VOCABULARY_SCHEMAS in scripts/generate_types.py), so there is nothing - # left to widen. What this clone still carries is instance normalization — - # a readback node accepts a manifest INSTANCE of a sibling class, which a - # plain pydantic model field does not. - {}, - validators={ - # Preserve the generated manifest's standalone-asset normalization, - # without inheriting from the input model. - "_coerce_standalone_assets": model_validator(mode="before")(_normalize_readback_manifest), - }, -) -_DeliveryVariant = _manifest_readback_clone( - "_DeliveryVariant", - CreativeVariant, - {"manifest": _ReadbackCreativeManifest | None}, -) -_BuildReadbackVariant = _manifest_readback_clone( - "_BuildReadbackVariant", - BuildCreativeVariant, - {"creative_manifest": _ReadbackCreativeManifest}, -) -_BuildReadbackCreative = _manifest_readback_clone( - "_BuildReadbackCreative", - BuildCreative, - { - # This constraint is inside the optional union in the generated type, - # so it must stay on the non-None arm when replacing that annotation. - "variants": Annotated[GenericAlias(list, _BuildReadbackVariant), Field(min_length=1)] - | None, - }, -) -_ReadbackOffer = _manifest_readback_clone( - "_ReadbackOffer", - Offer, - {"creative_manifest": _ReadbackCreativeManifest | None}, -) - - -def _patch_model_field(model: type[BaseModel], field_name: str, new_annotation: Any) -> None: - """Replace a Pydantic model field's annotation in-place. - - Sets both model_fields (the FieldInfo dict Pydantic uses for schema - generation) and __annotations__ (for introspection), then forces a - schema rebuild. Preserves all constraints and metadata from the original field. - """ - old_fi = model.model_fields.get(field_name) - if old_fi is None: - model.model_fields[field_name] = FieldInfo(annotation=new_annotation) - else: - # Keep every generated constraint and piece of field metadata. Rebuilding - # a FieldInfo from only its default and description silently drops items - # such as min_length, which matters for composability patches on lists. - new_fi = copy(old_fi) - new_fi.annotation = new_annotation - model.model_fields[field_name] = new_fi - model.__annotations__[field_name] = new_annotation - - -def _annotation_contains(annotation: Any, expected: type) -> bool: - """Return whether a possibly nested annotation contains ``expected``.""" - return annotation is expected or any( - _annotation_contains(arg, expected) for arg in get_args(annotation) - ) - - -def _patch_equivalent_model_field( - model: type[BaseModel], - field_name: str, - # A bundled clone is matched by class identity, so any class works. Not - # ``type[BaseModel]``: a bundled scalar root (``PublisherDomain``, - # ``PrimaryCountry``) is a ``str`` subclass, not a Pydantic model (#1277). - bundled_model: type, - canonical_annotation: Any, -) -> None: - """Replace a bundled clone only after verifying the generated field shape.""" - field = model.model_fields.get(field_name) - if field is None or not _annotation_contains(field.annotation, bundled_model): - actual = None if field is None else field.annotation - raise RuntimeError( - f"forward compatibility: {model.__name__}.{field_name} lost its " - f"bundled {bundled_model.__name__} annotation (got {actual!r})" - ) - _patch_model_field(model, field_name, canonical_annotation) - - -def _validate_targeting_overlay(value: Any, handler: ValidatorFunctionWrapHandler) -> Any: - """Keep the runtime compatibility union out of request-document error paths.""" - try: - return handler(value) - except ValidationError as exc: - # Raw request validation belongs to the Input schema. On failure the - # legacy arm repeats its errors, with synthetic class-name loc segments. - # Normalize at this field boundary so direct Pydantic errors, MCP, A2A, - # and CLI all agree, including callers that never run error narrowing. - # Only remove the leading Input arm label; nested genuine unions retain - # all of their locations and errors. Successful validation is unchanged. - input_errors: list[InitErrorDetails] = [] - for error in exc.errors(include_url=False): - if error["loc"][:1] != ("TargetingOverlayInput",): - continue - detail: InitErrorDetails = { - "type": error["type"], - "loc": error["loc"][1:], - "input": error["input"], - } - if "ctx" in error: - detail["ctx"] = error["ctx"] - input_errors.append(detail) - if not input_errors: - raise - raise ValidationError.from_exception_data(exc.title, input_errors) from exc - - -def _serialize_targeting_overlay(value: Any, handler: SerializerFunctionWrapHandler) -> Any: - """Use the compatibility union for both Python and JSON serialization.""" - return handler(value) - - -def _targeting_overlay_schema( - source: Any, handler: GetCoreSchemaHandler, *, materialized_json: bool -) -> CoreSchema: - """Keep JSON diagnostics native while bridging legacy Python objects.""" - input_schema = handler.generate_schema(TargetingOverlayInput | None) - compatibility_schema = handler(source) - json_schema = input_schema - if materialized_json: - # Canonical parents already materialize JSON in their before validator. - # Re-enter core JSON validation for just this subtree to restore native - # error codes and extra/missing error order. A core Json schema preserves - # caller strictness/context; a separate TypeAdapter call would not. - json_schema = core_schema.no_info_before_validator_function( - json.dumps, - core_schema.json_schema(input_schema), - json_schema_input_schema=input_schema, - ) - return core_schema.json_or_python_schema( - # Native JSON parents need no Python callback or round trip. In - # particular, retain diagnostics for repeated keys in the raw JSON. - json_schema=json_schema, - python_schema=core_schema.no_info_wrap_validator_function( - _validate_targeting_overlay, - compatibility_schema, - ), - # json-or-python otherwise selects the Input-only JSON serializer for - # to_json(), which cannot serialize legacy collection representations. - # Keep the original union serializer and advertise only mutation input. - serialization=core_schema.wrap_serializer_function_ser_schema( - _serialize_targeting_overlay, - schema=compatibility_schema, - ), - ) - - -def _patch_targeting_overlay(model: type[BaseModel]) -> None: - """Bridge beta.14 objects only while the generated mutation shape is unchanged.""" - field = model.model_fields.get("targeting_overlay") - if field is None or field.annotation != TargetingOverlayInput | None: - actual = None if field is None else field.annotation - raise RuntimeError( - f"forward compatibility: {model.__name__}.targeting_overlay lost its " - f"TargetingOverlayInput | None annotation (got {actual!r})" - ) - # The implementation classes above are the exact public overlay types. - # Importing them through the lazy facade here would re-enter _eager. - # Input first is essential: raw dicts keep their rc.3 mutation wrappers, - # while existing resolved-state instances (including subclasses) retain - # identity and internal fields without a lossy model_dump() conversion. - # The legacy arm is runtime-only: advertising it would widen and duplicate - # the mutation schema in model_json_schema() and MCP tools/list. - _patch_model_field( - model, - "targeting_overlay", - Annotated[ - TargetingOverlayInput | SkipJsonSchema[TargetingOverlay] | None, - Field(union_mode="left_to_right"), - GetPydanticSchema( - partial( - _targeting_overlay_schema, - materialized_json=bool( - getattr(model, "__adcp_canonical_creative_model__", False) - ), - ) - ), - ], - ) - model.model_rebuild(force=True) - - -def _apply_forward_compat() -> None: - """Apply open-union, capability, and public-model compatibility patches.""" - for model in ( - PackageRequest, - PackageUpdate, - PackageControl, - ProductPurchaseInput, - # aliases has already imported canonical_creative, whose public - # package facades copy FieldInfo rather than sharing generated fields. - PublicPackageRequest, - PublicPackageUpdate, - ): - _patch_targeting_overlay(model) - - # _ergonomic eagerly builds this legacy parent before the targeting patch. - # Refresh its cached nested validator as well as the package model itself. - _GeneratedCreateMediaBuyRequest.model_rebuild(force=True) - - # 3.2.1 permits additional registration fields but does not declare this ID. - # Keep the extension outside generated code so a schema refresh cannot drop - # it again. An eventual upstream core definition takes precedence. - if "operation_id" not in ReportingWebhook.model_fields: - # Pydantic accepts annotated unions at runtime; its helper's annotation - # is narrower than that supported dynamic input. - reporting_operation_id_annotation: Any = _ReportingOperationId - ReportingWebhook.model_fields["operation_id"] = FieldInfo.from_annotated_attribute( - reporting_operation_id_annotation, None - ) - ReportingWebhook.__annotations__["operation_id"] = _ReportingOperationId - ReportingWebhook.model_rebuild(force=True) - # Rebuild after targeting patches too: eager parent validators must - # capture both the reporting extension and patched package inputs. - for request in ( - AcceptProposalRequest, - BuyProductsRequest, - ControlMediaBuyRequest, - CreateMediaBuyRequest, - UpdateMediaBuyRequest, - _GeneratedCreateMediaBuyRequest, - _GeneratedUpdateMediaBuyRequest, - ): - request.model_rebuild(force=True) - - # Response manifests retain unknown future kinds because the field is - # ``str`` everywhere. These patches thread the readback NODES through the - # generated response classes, which is what lets a response accept a - # manifest instance of a sibling class. Nothing here is about strictness: - # no model refuses a format kind in either direction, and checking the - # vocabulary is the caller's through ``adcp.types.is_canonical_format_kind`` - # (see adcp.types.canonical_creative). - # ``format_kind`` needs no patch: it is generated as ``str``, so a response - # retains an unknown kind without one. - _patch_model_field(DeliveryCreative, "variants", GenericAlias(list, _DeliveryVariant)) - DeliveryCreative.model_rebuild(force=True) - GetCreativeDeliveryResponse.model_rebuild(force=True) - - _patch_model_field(PreviewCreativeResponse3, "manifest", _ReadbackCreativeManifest | None) - PreviewCreativeResponse3.model_rebuild(force=True) - _patch_model_field(BuildCreativeResponse1, "creative_manifest", _ReadbackCreativeManifest) - BuildCreativeResponse1.model_rebuild(force=True) - _patch_model_field( - BuildCreativeResponse3, "creative_manifests", GenericAlias(list, _ReadbackCreativeManifest) - ) - BuildCreativeResponse3.model_rebuild(force=True) - _patch_model_field( - BuildCreativeResponse4, "creatives", GenericAlias(list, _BuildReadbackCreative) - ) - BuildCreativeResponse4.model_rebuild(force=True) - - for response in (ContextMatchResponseRouterPublisher, ContextMatchResponseProviderRouter): - _patch_model_field(response, "offers", GenericAlias(list, _ReadbackOffer)) - response.model_rebuild(force=True) - - # This eager wrapper captured build/preview validators before the patches. - # Rebuilding it re-resolves AdcpAsyncResponseData, a union type alias, so - # completed task callbacks retain typed manifests. - McpWebhookPayload.model_rebuild(force=True) - - _patch_model_field(Format, "assets", list[FormatAssetUnion] | None) - Format.model_rebuild(force=True) - - _patch_model_field(RepeatableAssetGroup, "assets", list[GroupFormatAssetUnion]) - cast(type[BaseModel], RepeatableAssetGroup).model_rebuild(force=True) - - # The 3.1 canonical-creatives capability was published after the bundled - # generated model. Preserve it across code generation until the schema - # bundle catches up; negotiation must not silently discard this evidence. - canonical_creatives_annotation: Any = bool | None - bundled_media_buy_features_arms = [ - arm - for arm in get_args(BundledCapabilitiesMediaBuy.model_fields["features"].annotation) - if arm is not type(None) - ] - if len(bundled_media_buy_features_arms) != 1: - raise RuntimeError( - "forward compatibility: MediaBuy.features lost its concrete model " - f"(got {bundled_media_buy_features_arms!r})" - ) - bundled_media_buy_features = bundled_media_buy_features_arms[0] - for features_model in (MediaBuyFeatures, bundled_media_buy_features): - features_model.model_fields["canonical_creatives"] = FieldInfo( - annotation=canonical_creatives_annotation, - default=None, - description=( - "Advertises canonical creative identity on AdCP 3.1. AdCP 3.2+ " - "is canonical by contract." - ), - ) - features_model.__annotations__["canonical_creatives"] = canonical_creatives_annotation - features_model.model_rebuild(force=True) - - # Canonical schemas referenced through the bundled capabilities schema are - # generated a second time as identity-distinct Pydantic classes. Accept the - # public SDK classes at these capability boundaries so a typed object does - # not have to be round-tripped through model_dump() before composition. - _patch_equivalent_model_field( - BundledCapabilitiesMediaBuy, - "acceptance_policy_discovery", - BundledAcceptancePolicyDiscovery, - AcceptancePolicyDiscovery | None, - ) - BundledCapabilitiesMediaBuy.model_rebuild(force=True) - - _patch_equivalent_model_field( - BundledCapabilitiesPortfolio, - "publisher_domains", - BundledPublisherDomain, - list[PublisherDomain], - ) - _patch_equivalent_model_field( - BundledCapabilitiesPortfolio, - "primary_countries", - BundledPrimaryCountry, - list[PrimaryCountry] | None, - ) - BundledCapabilitiesPortfolio.model_rebuild(force=True) - -_apply_forward_compat() +# The generated classes carry ``operation_id`` -- ``PIN_GAP_FIELDS`` in +# scripts/generate_types.py declares it on core/reporting-webhook.json and the +# generator emits it, so all five nesting request schemas get it by generation. +# A TYPE_CHECKING shadow used to restate the field on each of those five plus +# one per split-root branch; a generated field needs no static view. +ReportingWebhook = _GeneratedReportingWebhook +AcceptProposalRequest = _GeneratedAcceptProposalRequest +BuyProductsRequest = _GeneratedBuyProductsRequest +ControlMediaBuyRequest = _GeneratedControlMediaBuyRequest +CreateMediaBuyRequest = _PublicCreateMediaBuyRequest +UpdateMediaBuyRequest = _PublicUpdateMediaBuyRequest diff --git a/src/adcp/types/_generated.py b/src/adcp/types/_generated.py index b49051c4f..c39f7e188 100644 --- a/src/adcp/types/_generated.py +++ b/src/adcp/types/_generated.py @@ -14,7 +14,7 @@ DO NOT EDIT MANUALLY. Generated from: https://github.com/adcontextprotocol/adcp/tree/main/schemas -Generation date: 2026-10-04 18:45:09 UTC +Generation date: 2026-10-16 22:14:47 UTC """ # ruff: noqa: E501, I001 @@ -294,6 +294,9 @@ from adcp.types.domains.account.list_account_changes_response import ( Kind, ListAccountChangesResponse, + ListAccountChangesResponseBranches, + ListAccountChangesResponseCompleted, + ListAccountChangesResponseFailed, SourceCoverageItem, Status22, ) @@ -459,7 +462,10 @@ Orientation, RelationshipTrust, SearchBrandResult, + SearchBrandsError, SearchBrandsResponse, + SearchBrandsResponseBranches, + SearchBrandsSuccess, ) from adcp.types.domains.brand.update_rights_request import UpdateRightsRequest from adcp.types.domains.brand.update_rights_response import ( @@ -468,7 +474,15 @@ UpdateRightsResponse2, ) from adcp.types.domains.brand.verification_status import VerificationStatus -from adcp.types.domains.brand.verify_brand_claim_request import ClaimType, VerifyBrandClaimRequest +from adcp.types.domains.brand.verify_brand_claim_request import ( + ClaimType, + VerifyBrandClaimRequest, + VerifyBrandClaimRequestBranches, + VerifyBrandClaimRequestVerifyParentClaim, + VerifyBrandClaimRequestVerifyPropertyClaim, + VerifyBrandClaimRequestVerifySubsidiaryClaim, + VerifyBrandClaimRequestVerifyTrademarkClaim, +) from adcp.types.domains.brand.verify_brand_claim_response import ( VerifyBrandClaimErrorResponse, VerifyBrandClaimPayload, @@ -852,6 +866,9 @@ ) from adcp.types.domains.core.audience_characteristic import ( AudienceCharacteristic, + AudienceCharacteristicBranches, + AudienceCharacteristicRange, + AudienceCharacteristicValue, Dimension, Range, Taxonomy, @@ -1076,7 +1093,16 @@ CatalogItemReferenceNotFoundError, ) from adcp.types.domains.core.catalog_selection import CatalogSelection -from adcp.types.domains.core.catchment import Catchment, Geometry, Radius, TravelTime +from adcp.types.domains.core.catchment import ( + Catchment, + CatchmentBranches, + CatchmentGeometry, + CatchmentRadius, + CatchmentTransportModeTravelTime, + Geometry, + Radius, + TravelTime, +) from adcp.types.domains.core.collection import Collection, RelatedCollection from adcp.types.domains.core.collection_delivery_metrics import CollectionDeliveryMetrics from adcp.types.domains.core.collection_distribution import CollectionDistribution @@ -1105,7 +1131,13 @@ from adcp.types.domains.core.content_rating import ContentRating from adcp.types.domains.core.context import ContextObject from adcp.types.domains.core.creative_approval_scope import ScopedCreativeApproval -from adcp.types.domains.core.creative_asset import CreativeAsset, Input +from adcp.types.domains.core.creative_asset import ( + CreativeAsset, + CreativeAssetBranches, + CreativeAssetV1CreativeNamedFormatReference, + CreativeAssetV2CreativeCanonicalFormatKind, + Input, +) from adcp.types.domains.core.creative_assets import CreativeAssets, CreativeAssets1 from adcp.types.domains.core.creative_assignment import CreativeAssignment, RotationMode from adcp.types.domains.core.creative_brief import ( @@ -1136,7 +1168,12 @@ Variants1, Variants2, ) -from adcp.types.domains.core.creative_manifest import CreativeManifest +from adcp.types.domains.core.creative_manifest import ( + CreativeManifest, + CreativeManifestBranches, + CreativeManifestCanonicalFormatManifest, + CreativeManifestNamedFormatManifest, +) from adcp.types.domains.core.creative_operation_format_declaration import ( CreativeOperationFormatDeclaration, ) @@ -1344,12 +1381,16 @@ Dimensions1, DisclosureCapability, Format, + FormatAssetUnion, FormatCard, FormatCardDetailed, + GroupFormatAssetUnion, Renders, Renders1, Responsive, SelectionMode, + UnknownFormatAsset, + UnknownGroupAsset, ) from adcp.types.domains.core.format_id import FormatReferenceStructuredObject from adcp.types.domains.core.format_option_ref import ( @@ -1364,10 +1405,16 @@ from adcp.types.domains.core.frequency_cap_impression_constraints import ( AllowedValue, FrequencyCapImpressionConstraints, + FrequencyCapImpressionConstraintsAllowedValues, + FrequencyCapImpressionConstraintsBranches, + FrequencyCapImpressionConstraintsMaximumMinimum, ) from adcp.types.domains.core.frequency_cap_interval_constraints import ( AllowedInterval, FrequencyCapIntervalConstraints, + FrequencyCapIntervalConstraintsAllowedIntervals, + FrequencyCapIntervalConstraintsBranches, + FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval, ) from adcp.types.domains.core.frequency_cap_requirements import FrequencyCapRequirements from adcp.types.domains.core.generation_credential import GenerationCredential @@ -1409,6 +1456,9 @@ from adcp.types.domains.core.geo_region_support import Countries3, GeographicRegionSupport from adcp.types.domains.core.get_geo_place_resolution_request import ( GetGeographicPlaceResolutionRequest, + GetGeographicPlaceResolutionRequestBranches, + GetGeographicPlaceResolutionRequestQ, + GetGeographicPlaceResolutionRequestValue, ) from adcp.types.domains.core.get_geo_place_resolution_response import ( GetGeographicPlaceResolutionResponse, @@ -2370,7 +2420,12 @@ from adcp.types.domains.core.warning_resource import WarningAffectedResource from adcp.types.domains.core.webhook_activity_record import WebhookActivityRecord from adcp.types.domains.core.webhook_challenge import WebhookChallenge -from adcp.types.domains.core.webhook_challenge_response import WebhookChallengeResponse +from adcp.types.domains.core.webhook_challenge_response import ( + WebhookChallengeResponse, + WebhookChallengeResponseBranches, + WebhookChallengeResponseChallenge, + WebhookChallengeResponseToken, +) from adcp.types.domains.core.wholesale_feed_event import ( AffectedEntityType, AppliesTo, @@ -2986,6 +3041,10 @@ ArtifactWebhook, BatchFrequency, CreateMediaBuyRequest, + CreateMediaBuyRequestBranches, + CreateMediaBuyRequestCommittedProposalExecution, + CreateMediaBuyRequestExplicitPackagesWithFixedAllocation, + CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation, DeliveryMode, ) from adcp.types.domains.media_buy.create_media_buy_response import ( @@ -3224,7 +3283,12 @@ from adcp.types.domains.media_buy.request_proposals_async_response_working import ( RequestProposalsWorking, ) -from adcp.types.domains.media_buy.request_proposals_request import RequestProposalsRequest +from adcp.types.domains.media_buy.request_proposals_request import ( + RequestProposalsRequest, + RequestProposalsRequestAccount, + RequestProposalsRequestBranches, + RequestProposalsRequestBrand, +) from adcp.types.domains.media_buy.request_proposals_response import ( IncompleteItem5, IncompleteItem6, @@ -4015,6 +4079,9 @@ "AudienceActivationMethods4", "AudienceActivationMethods5", "AudienceCharacteristic", + "AudienceCharacteristicBranches", + "AudienceCharacteristicRange", + "AudienceCharacteristicValue", "AudienceConstraints", "AudienceDistribution", "AudienceDistribution1", @@ -4367,6 +4434,10 @@ "CatalogSelection", "CatalogType", "Catchment", + "CatchmentBranches", + "CatchmentGeometry", + "CatchmentRadius", + "CatchmentTransportModeTravelTime", "Category", "ChangeFeed", "ChangeFeed1", @@ -4541,6 +4612,10 @@ "CreateContentStandardsResponse2", "CreateMediaBuyInputRequired", "CreateMediaBuyRequest", + "CreateMediaBuyRequestBranches", + "CreateMediaBuyRequestCommittedProposalExecution", + "CreateMediaBuyRequestExplicitPackagesWithFixedAllocation", + "CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation", "CreateMediaBuyResponse", "CreateMediaBuyResponse1", "CreateMediaBuyResponse2", @@ -4564,6 +4639,9 @@ "CreativeApprovalResponse4", "CreativeApprovalStatus", "CreativeAsset", + "CreativeAssetBranches", + "CreativeAssetV1CreativeNamedFormatReference", + "CreativeAssetV2CreativeCanonicalFormatKind", "CreativeAssets", "CreativeAssets1", "CreativeAssignment", @@ -4584,6 +4662,9 @@ "CreativeLocalization", "CreativeLocalizationReadback", "CreativeManifest", + "CreativeManifestBranches", + "CreativeManifestCanonicalFormatManifest", + "CreativeManifestNamedFormatManifest", "CreativeMotionLevel", "CreativeOperationFormatDeclaration", "CreativePolicy", @@ -4859,6 +4940,7 @@ "ForecastVendorMetricValue", "ForecastableMetric", "Format", + "FormatAssetUnion", "FormatCard", "FormatCardDetailed", "FormatId", @@ -4876,7 +4958,13 @@ "FrequencyCapControlMode", "FrequencyCapDurationUnit", "FrequencyCapImpressionConstraints", + "FrequencyCapImpressionConstraintsAllowedValues", + "FrequencyCapImpressionConstraintsBranches", + "FrequencyCapImpressionConstraintsMaximumMinimum", "FrequencyCapIntervalConstraints", + "FrequencyCapIntervalConstraintsAllowedIntervals", + "FrequencyCapIntervalConstraintsBranches", + "FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval", "FrequencyCapMutableField", "FrequencyCapRequirements", "FrequencyCapScope", @@ -4945,6 +5033,9 @@ "GetCreativeFeaturesSubmitted", "GetCreativeFeaturesSuccess", "GetGeographicPlaceResolutionRequest", + "GetGeographicPlaceResolutionRequestBranches", + "GetGeographicPlaceResolutionRequestQ", + "GetGeographicPlaceResolutionRequestValue", "GetGeographicPlaceResolutionResponse", "GetMediaBuyArtifactsRequest", "GetMediaBuyArtifactsResponse", @@ -4995,6 +5086,7 @@ "GovernedAction", "GradingProfile", "GrantStatus", + "GroupFormatAssetUnion", "Gtin", "Handoff", "HdrFormat", @@ -5160,6 +5252,9 @@ "List", "ListAccountChangesRequest", "ListAccountChangesResponse", + "ListAccountChangesResponseBranches", + "ListAccountChangesResponseCompleted", + "ListAccountChangesResponseFailed", "ListAccountsRequest", "ListAccountsResponse", "ListCollectionListsRequest", @@ -5963,6 +6058,9 @@ "Request", "RequestProposalsInputRequired", "RequestProposalsRequest", + "RequestProposalsRequestAccount", + "RequestProposalsRequestBranches", + "RequestProposalsRequestBrand", "RequestProposalsResponse", "RequestProposalsResponse1", "RequestProposalsResponse2", @@ -6075,8 +6173,11 @@ "ScrollReference", "SealedCredential", "SearchBrandResult", + "SearchBrandsError", "SearchBrandsRequest", "SearchBrandsResponse", + "SearchBrandsResponseBranches", + "SearchBrandsSuccess", "Security", "SeedSource", "Segment", @@ -6449,6 +6550,8 @@ "UnavailableReason", "Unit", "UniversalMacro", + "UnknownFormatAsset", + "UnknownGroupAsset", "UnknownHandling", "UnmatchedLocaleAction", "UnmetRequirement", @@ -6572,6 +6675,11 @@ "VerifyBrandClaimErrorResponse", "VerifyBrandClaimPayload", "VerifyBrandClaimRequest", + "VerifyBrandClaimRequestBranches", + "VerifyBrandClaimRequestVerifyParentClaim", + "VerifyBrandClaimRequestVerifyPropertyClaim", + "VerifyBrandClaimRequestVerifySubsidiaryClaim", + "VerifyBrandClaimRequestVerifyTrademarkClaim", "VerifyBrandClaimResponse", "VerifyBrandClaimSignedResponse", "VerifyBrandClaimSignedSuccessPayload", @@ -6620,6 +6728,9 @@ "WebhookAssetRequirements", "WebhookChallenge", "WebhookChallengeResponse", + "WebhookChallengeResponseBranches", + "WebhookChallengeResponseChallenge", + "WebhookChallengeResponseToken", "WebhookResponseType", "WebhookSecurityMethod", "WebhookSigning", @@ -6675,5 +6786,10 @@ from adcp.types import domains # noqa: F401 # Rebuild models that reference other models via forward refs -# Note: only call model_rebuild() on actual classes, not Union type aliases -CreativeManifest.model_rebuild() +# A name here can be a union alias over a split root's branches, which has +# no model_rebuild of its own. classes_behind is the one derivation of +# what a name stands for: the shared base and each branch, or the model. +from adcp.types._split_root import classes_behind + +for _rebuilt in classes_behind(CreativeManifest): + _rebuilt.model_rebuild() diff --git a/src/adcp/types/_readback.py b/src/adcp/types/_readback.py new file mode 100644 index 000000000..971683016 --- /dev/null +++ b/src/adcp/types/_readback.py @@ -0,0 +1,98 @@ +"""The tolerant node a RESPONSE carries where a request carries a strict input. + +A read-back manifest must accept what a seller sends: an unknown field kept +rather than dropped, and a manifest INSTANCE of a sibling class rather than a +dict. A request model must not accept either -- #1241 asked that delivery +tolerance not weaken creative input validation -- so the tolerance cannot go on +the input class. It goes on a separate class that COPIES the input's fields +without inheriting from it, which is what this module builds. + +The factory takes the source class as an argument and imports nothing from the +generated tree, so a generated module can import it. The clone definitions +themselves are EMITTED into the generated modules by ``READBACK_CLONES`` in +``scripts/post_generate_fixes.py``, next to the class each one copies. + +Why emitted rather than kept here: ``_BuildReadbackCreative`` copies +``media_buy/build_creative_response.Creative``, and the field that carries it -- +``BuildCreativeResponse4.creatives`` -- is in that same module. A hand-written +module holding the clone would have to import that module while that module +imported the clone. Every other clone could live here; one of them cannot, and +one mechanism for all five is worth more than four of them living somewhere +tidier. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from pydantic import ConfigDict, create_model, model_validator + +from adcp.types._split_root import shared_base +from adcp.types.base import AdCPBaseModel +from adcp.types.domains.core.version_envelope import AdcpVersionEnvelope + +__all__ = ["readback_clone", "readback_normalizer"] + + +class _ManifestReadbackModel(AdCPBaseModel): + """Independent from strict inputs: tolerant instances must not validate as them.""" + + model_config = ConfigDict(extra="allow") + + @model_validator(mode="before") + @classmethod + def _normalize_readback(cls, data: Any) -> Any: + if isinstance(data, AdCPBaseModel) and not isinstance(data, cls): + return data.model_dump(mode="python") + return data + + +class _VersionedManifestReadbackModel(AdcpVersionEnvelope, _ManifestReadbackModel): + """Keep the shared version envelope on build response nodes.""" + + +def readback_clone( + name: str, + source: type[AdCPBaseModel], + overrides: dict[str, Any] | None = None, + *, + validators: dict[str, Any] | None = None, +) -> type[AdCPBaseModel]: + """A tolerant model carrying ``source``'s fields without inheriting from it. + + Copies all field constraints. A tolerant SUBCLASS would pass its parent's + own instance validation, which is the bypass #1241 is about. + """ + fields: dict[str, Any] = { + key: ((overrides or {}).get(key, field.annotation), deepcopy(field)) + for key, field in source.model_fields.items() + } + return create_model( + name, + __base__=( + _VersionedManifestReadbackModel + if issubclass(source, AdcpVersionEnvelope) + else _ManifestReadbackModel + ), + __module__=source.__module__, + __validators__=validators, + **fields, + ) + + +def readback_normalizer(source: Any, attribute: str) -> Any: + """Bind ``source``'s own before-validator, for the clone to reuse. + + The clone does not inherit from ``source``, so it does not inherit the + normalization either -- and a read-back manifest's assets normalize exactly + the way an input manifest's do. Resolved on each call rather than at + definition: pydantic binds the generated validator proxy to a callable at + runtime, and the validator is declared on the shared base, because the + branches differ in which fields they require, not in how assets normalize. + """ + + def normalize(data: Any) -> Any: + return getattr(shared_base(source), attribute)(data) + + return normalize diff --git a/src/adcp/types/_split_root.py b/src/adcp/types/_split_root.py new file mode 100644 index 000000000..e54e9ceec --- /dev/null +++ b/src/adcp/types/_split_root.py @@ -0,0 +1,73 @@ +"""The one derivation for "which classes stand behind this name". + +A schema root whose branches partition by requiredness is generated as a shared +base plus one class per branch, with the root's public name bound to a union over +the branches. Three places then need the same fact, and each got it wrong in its +own way before this existed: + +* the ergonomic-coercion generator analyses a model's fields -- a union has + none; +* the consolidated-export module rebuilds forward references -- a union has no + ``model_rebuild``, and the generated comment beside those calls already said + "only call model_rebuild() on actual classes, not Union type aliases" without + any way to tell; +* a read-back clone copies the FIELDS of a root whose branches differ only in + which of them are required, so it has to copy the base's -- which is why + ``READBACK_CLONES`` in ``scripts/post_generate_fixes.py`` passes + ``shared_base(CreativeManifest)`` and not the public name. (The third reader + used to be ``_forward_compat``, which patched a base and then rebuilt every + branch, because a subclass caches its parent's annotations when it is built. + Nothing patches a field any more; the derivation outlived the reason for it.) + +So the derivation lives here once. The base is read off an branch's MRO rather +than looked up by name or spelled out: the branches all name it, which is the +property the emitter's own tests grade, and reading it this way means this +module does not have to know how the base is spelled. +""" + +from __future__ import annotations + +from typing import Annotated, Any, get_args, get_origin + +__all__ = ["branch_members", "classes_behind", "shared_base"] + + +def branch_members(declared: Any) -> tuple[Any, ...]: + """The branch classes a split root's public name stands for, or ``()``. + + The alias is ``Annotated[A | B, WrapValidator(...)]``, because a refusal has + to name the root's combinator. ``get_args`` on that returns the union and + the metadata rather than the members, so every reader unwraps the annotation + first. One owner for that, because three readers need it and each would get + it subtly wrong: a reader that takes ``get_args(...)[0]`` and calls + ``issubclass`` on it raises ``TypeError``, which is how this was found. + """ + if get_origin(declared) is Annotated: + declared = get_args(declared)[0] + members = get_args(declared) + if not members or not all(isinstance(member, type) for member in members): + return () + return members + + +def classes_behind(declared: Any) -> tuple[Any, ...]: + """Every class the name stands for, base first, or just the model itself. + + For a split root: the shared base followed by each branch, which is the + order a patch-then-rebuild needs -- the base carries the change and every + branch has to be rebuilt to see it. + """ + members = branch_members(declared) + if not members: + return (declared,) + return (members[0].__mro__[1], *members) + + +def shared_base(declared: Any) -> Any: + """What carries the fields: the shared base for a split root, else the model. + + This is the target of anything that reads or patches a FIELD. The branches only + change which fields are required, forbidden or pinned, so the field set is + the base's and patching it once covers every branch. + """ + return classes_behind(declared)[0] diff --git a/src/adcp/types/_targeting_compat.py b/src/adcp/types/_targeting_compat.py new file mode 100644 index 000000000..b264d9042 --- /dev/null +++ b/src/adcp/types/_targeting_compat.py @@ -0,0 +1,170 @@ +"""The rc.3 mutation overlay type, and the beta.14 object it still accepts. + +A mutation field declares ``TargetingOverlayInput``: that is the schema's +shape, and it is what the JSON schema and MCP ``tools/list`` advertise. The +SDK additionally accepts an already-resolved ``TargetingOverlay`` instance, +because a caller that read an overlay off a response and passed it back should +not have to round-trip it through ``model_dump()`` first. + +This module holds that type as two aliases, and the generated mutation fields +carry one of them BY GENERATION -- ``COMPATIBILITY_ANNOTATIONS`` in +``scripts/post_generate_fixes.py`` writes it at each field's declaration. It +used to be patched onto four generated classes and two canonical facades after +import, which reached only those six classes: a model that nested one of them +by its generated type got the un-bridged field. + +Import layering: this module imports the generated targeting modules, and the +generated mutation modules import this one. There is no cycle because +``core/targeting.py`` and ``core/targeting_input.py`` import nothing from +``media_buy/``. +""" + +from __future__ import annotations + +import json +from functools import partial +from typing import Annotated, Any + +from pydantic import ( + BaseModel, + Field, + GetCoreSchemaHandler, + GetPydanticSchema, + SerializerFunctionWrapHandler, + ValidationError, + ValidatorFunctionWrapHandler, +) +from pydantic.json_schema import SkipJsonSchema +from pydantic_core import CoreSchema, InitErrorDetails, core_schema + +from adcp.types.domains.core.targeting import TargetingOverlay +from adcp.types.domains.core.targeting_input import TargetingOverlayInput + +__all__ = [ + "TargetingOverlayMutation", + "TargetingOverlayMutationInMaterializedJson", + "mutation_overlay_field", +] + + +def _validate_targeting_overlay(value: Any, handler: ValidatorFunctionWrapHandler) -> Any: + """Keep the runtime compatibility union out of request-document error paths.""" + try: + return handler(value) + except ValidationError as exc: + # Raw request validation belongs to the Input schema. On failure the + # legacy branch repeats its errors, with synthetic class-name loc segments. + # Normalize at this field boundary so direct Pydantic errors, MCP, A2A, + # and CLI all agree, including callers that never run error narrowing. + # Only remove the leading Input branch label; nested genuine unions retain + # all of their locations and errors. Successful validation is unchanged. + input_errors: list[InitErrorDetails] = [] + for error in exc.errors(include_url=False): + if error["loc"][:1] != ("TargetingOverlayInput",): + continue + detail: InitErrorDetails = { + "type": error["type"], + "loc": error["loc"][1:], + "input": error["input"], + } + if "ctx" in error: + detail["ctx"] = error["ctx"] + input_errors.append(detail) + if not input_errors: + raise + raise ValidationError.from_exception_data(exc.title, input_errors) from exc + + +def _serialize_targeting_overlay(value: Any, handler: SerializerFunctionWrapHandler) -> Any: + """Use the compatibility union for both Python and JSON serialization.""" + return handler(value) + + +def _targeting_overlay_schema( + source: Any, handler: GetCoreSchemaHandler, *, materialized_json: bool +) -> CoreSchema: + """Keep JSON diagnostics native while bridging legacy Python objects.""" + input_schema = handler.generate_schema(TargetingOverlayInput | None) + compatibility_schema = handler(source) + json_schema = input_schema + if materialized_json: + # A canonical parent materializes JSON in its before validator. Re-enter + # core JSON validation for just this subtree to restore native error codes + # and extra/missing error order. A core Json schema preserves caller + # strictness/context; a separate TypeAdapter call would not. + json_schema = core_schema.no_info_before_validator_function( + json.dumps, + core_schema.json_schema(input_schema), + json_schema_input_schema=input_schema, + ) + return core_schema.json_or_python_schema( + # Native JSON parents need no Python callback or round trip. In + # particular, retain diagnostics for repeated keys in the raw JSON. + json_schema=json_schema, + python_schema=core_schema.no_info_wrap_validator_function( + _validate_targeting_overlay, + compatibility_schema, + ), + # json-or-python otherwise selects the Input-only JSON serializer for + # to_json(), which cannot serialize legacy collection representations. + # Keep the original union serializer and advertise only mutation input. + serialization=core_schema.wrap_serializer_function_ser_schema( + _serialize_targeting_overlay, + schema=compatibility_schema, + ), + ) + + +#: The union both aliases carry. Input FIRST is essential: a raw dict keeps its rc.3 +#: mutation wrappers, while an already-resolved instance (including a subclass) retains +#: identity and internal fields without a lossy model_dump() conversion. The legacy +#: branch is runtime-only -- advertising it would widen and duplicate the mutation shape +#: in model_json_schema() and in MCP tools/list. +_MutationUnion = TargetingOverlayInput | SkipJsonSchema[TargetingOverlay] | None + +#: What a generated mutation field carries. ``Annotated`` flattens, so the generated +#: declaration keeps its own ``Field(description=...)`` beside this. +#: +#: Spelled out rather than built by a factory taking the flag: mypy reads an implicit +#: type alias only from a literal ``Annotated[...]``, and a call is a variable, not a +#: type. The two differ in one token. +TargetingOverlayMutation = Annotated[ + _MutationUnion, + Field(union_mode="left_to_right"), + GetPydanticSchema(partial(_targeting_overlay_schema, materialized_json=False)), +] + +#: The same type for a mutation field whose enclosing model materializes JSON before its +#: fields are validated -- every ``CanonicalBoundaryModel`` does. Both directions of this +#: split were measured: forcing False everywhere fails six JSON error-ordering cases in +#: tests/test_targeting_overlay_errors.py, and forcing True everywhere fails the two +#: repeated-key diagnostics cases there. +#: +#: A canonical model subclasses its generated parent, so it REDECLARES the field with +#: this alias. A pydantic subclass that redeclares a field replaces the whole +#: ``FieldInfo``, so the declaration assigns ``canonical_creative._inherit(...)`` to keep +#: the generated description and constraints -- dropping the description was measured to +#: fail ``test_advertised_targeting_mutations_keep_input_only_schema[update_media_buy]``. +TargetingOverlayMutationInMaterializedJson = Annotated[ + _MutationUnion, + Field(union_mode="left_to_right"), + GetPydanticSchema(partial(_targeting_overlay_schema, materialized_json=True)), +] + + +def mutation_overlay_field(parent: type[BaseModel]) -> Any: + """The generated field's default and description, for a canonical redeclaration. + + NOT ``canonical_creative._inherit``, which deep-copies the whole ``FieldInfo``. + That is right for a field whose parent carries only schema metadata, and wrong + here: the generated parent's ``FieldInfo`` already carries THIS annotation's + metadata (``union_mode`` and the ``GetPydanticSchema``), so copying it applies + both twice and pydantic raises ``Unable to apply constraint 'union_mode' to + schema of type 'json-or-python'`` -- measured, not predicted. + + Default and description are the whole of what the generated field carries + besides that metadata, so nothing is dropped: alias, title, constraints, + json_schema_extra and the rest are all unset on all four of these fields. + """ + field = parent.model_fields["targeting_overlay"] + return Field(default=field.default, description=field.description) diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index 276f99e93..88a90f03d 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 BeforeValidator, ConfigDict, Discriminator, Tag +from pydantic import BeforeValidator, Discriminator from adcp.types import _generated as _g from adcp.types._generated import ( @@ -553,7 +553,7 @@ def _generated_alias(name: str, fallback_name: str) -> Any: """Account reference union without a generated ``RootModel`` wrapper. Validate untrusted data with ``TypeAdapter(AccountReference)``. Construct a -known arm with :class:`AccountReferenceById` or +known branch with :class:`AccountReferenceById` or :class:`AccountReferenceByNaturalKey`. """ @@ -603,7 +603,7 @@ def _generated_alias(name: str, fallback_name: str) -> Any: # 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. +# annotations without imposing another wrapper around their constituent branches. PostalArea = _Annotated[ PostalArea2 | PostalArea1, BeforeValidator(_g.PostalArea._validate_country_system_pairing), @@ -651,12 +651,12 @@ def _generated_alias(name: str, fallback_name: str) -> Any: # Get Products Response Variants # # The rc.9 ``get_products_response`` schema is a single flat success -# object — the async arms (submitted / working / input_required) ship as +# object — the async branches (submitted / working / input_required) ship as # separate ``core/async_response_refs`` schemas, NOT unioned into the # top-level response. So ``GetProductsResponse`` (the public name) stays # the constructable success class; these aliases name the orphaned async -# arms semantically, mirroring ``CreateMediaBuySubmittedResponse`` even -# though create_media_buy's submitted arm IS unioned into its generated +# branches semantically, mirroring ``CreateMediaBuySubmittedResponse`` even +# though create_media_buy's submitted branch IS unioned into its generated # response (its schema is a oneOf, get_products' is not). GetProductsSuccessResponse: TypeAlias = _GetProductsSuccessResponse """Success response - product catalog issued in-line (synchronous read).""" @@ -665,7 +665,7 @@ def _generated_alias(name: str, fallback_name: str) -> Any: """Submitted (async) envelope - brief / refine discovery handed off to a background task. Carries ``task_id`` + ``status='submitted'``; the ``products`` array is issued on the completion artifact (poll -``tasks/get``), not here. Wholesale calls MUST NOT produce this arm.""" +``tasks/get``), not here. Wholesale calls MUST NOT produce this branch.""" GetProductsWorkingResponse: TypeAlias = GetProductsWorking """Working (async) progress envelope for an in-flight get_products task.""" @@ -675,7 +675,7 @@ def _generated_alias(name: str, fallback_name: str) -> Any: solicit buyer clarification (CLARIFICATION_NEEDED / BUDGET_REQUIRED).""" #: Full async-aware union for ``get_products``. Includes the synchronous -#: success arm plus the three async arms the rc.9 spec ships for this +#: success branch plus the three async branches the rc.9 spec ships for this #: verb. The public ``GetProductsResponse`` name remains the success #: class (so direct construction / ``model_validate`` keep working); #: this union is the honest type of "any get_products response shape on @@ -689,7 +689,7 @@ def _generated_alias(name: str, fallback_name: str) -> Any: # Get Signals Response Variants # -# get_signals ships ONLY submitted + working async arms — NO +# get_signals ships ONLY submitted + working async branches — NO # input_required (signal discovery cannot pause to solicit buyer input). # Same flat-success-schema posture as get_products. GetSignalsSuccessResponse: TypeAlias = _GetSignalsSuccessResponse @@ -699,14 +699,14 @@ def _generated_alias(name: str, fallback_name: str) -> Any: """Submitted (async) envelope - brief discovery handed off to a background task. Carries ``task_id`` + ``status='submitted'``; the ``signals`` array is issued on the completion artifact. Wholesale calls -MUST NOT produce this arm.""" +MUST NOT produce this branch.""" GetSignalsWorkingResponse: TypeAlias = GetSignalsWorking """Working (async) progress envelope for an in-flight get_signals task.""" #: Full async-aware union for ``get_signals``. Includes the synchronous -#: success arm plus the two async arms (submitted / working). Has NO -#: input_required arm — narrower than ``GetProductsResponseUnion``. +#: success branch plus the two async branches (submitted / working). Has NO +#: input_required branch — narrower than ``GetProductsResponseUnion``. GetSignalsResponseUnion: TypeAlias = ( _GetSignalsSuccessResponse | GetSignalsSubmitted | GetSignalsWorking ) @@ -852,10 +852,10 @@ def process_result(result: SyncCatalogResult) -> None: UpdateMediaBuySubmittedResponse: TypeAlias = UpdateMediaBuyResponse3 """Submitted (async) envelope - media buy update accepted for async processing.""" -# One Legacy name per arm of this union, not two of the three (#1399). The -# generated arm is what a seller holds after reading a response off the wire; +# One Legacy name per branch of this union, not two of the three (#1399). The +# generated branch is what a seller holds after reading a response off the wire; # the bare names above are the canonical SUBCLASSES, so without these the only -# public spelling for the arm itself was an ``adcp.types.domains`` path. +# public spelling for the branch itself was an ``adcp.types.domains`` path. LegacyUpdateMediaBuySuccessResponse: TypeAlias = UpdateMediaBuyResponse1 """Exact legacy-generated update success shape for private-import migration.""" @@ -1887,164 +1887,40 @@ def _repeatable_asset_group_class() -> type: """Webhook asset slot nested in a RepeatableAssetGroup (asset_type='webhook').""" # ============================================================================ -# OPEN UNION TYPES — forward-compat fallback arms for Format.assets +# OPEN UNION TYPES — forward-compat fallback branches for Format.assets # ============================================================================ # AdCP enums grow additively by design. When a new asset_type arrives before # the SDK is updated, the closed discriminated union in the generated tree raises a -# cascade of ValidationErrors (one per arm per slot) and zeroes out the entire +# cascade of ValidationErrors (one per branch per slot) and zeroes out the entire # list_creative_formats catalog. These open union types add an unknown fallback -# arm so callers receive unrecognized assets as typed-unknown rather than +# branch so callers receive unrecognized assets as typed-unknown rather than # losing every format in the response. # # Asymmetric strictness (Postel's Law): -# - Emit path stays strict: request/write types keep closed Literal arms. +# - Emit path stays strict: request/write types keep closed Literal branches. # - Parse path is lenient: response/read types accept novel discriminators -# via UnknownFormatAsset / UnknownGroupAsset fallback arms. +# via UnknownFormatAsset / UnknownGroupAsset fallback branches. # # The callable Discriminator + Tag pattern is the Pydantic v2 ≥2.5 canonical -# approach for open discriminated unions. _apply_forward_compat() in -# _forward_compat.py patches Format.assets and Assets94.assets with these -# types at import time using model_rebuild(force=True). - -_KNOWN_INDIVIDUAL_ASSET_TYPES: frozenset[str] = frozenset( - { - "image", - "video", - "audio", - "text", - "markdown", - "html", - "css", - "javascript", - "vast", - "daast", - "url", - "webhook", - "brief", - "catalog", - } -) - -_KNOWN_GROUP_ASSET_TYPES: frozenset[str] = frozenset( - { - "image", - "video", - "audio", - "text", - "markdown", - "html", - "css", - "javascript", - "vast", - "daast", - "url", - "webhook", - } -) - - -def _format_asset_discriminator(v: Any) -> str: - """Route to the correct Tag for Format.assets callable Discriminator.""" - if isinstance(v, dict): - item_type: str = v.get("item_type", "individual") - asset_type: str = v.get("asset_type", "") - else: - item_type = getattr(v, "item_type", "individual") - asset_type = getattr(v, "asset_type", "") - if item_type == "repeatable_group": - return "repeatable_group" - return asset_type if asset_type in _KNOWN_INDIVIDUAL_ASSET_TYPES else "_unknown" - - -def _group_asset_discriminator(v: Any) -> str: - """Route to the correct Tag for Assets94.assets callable Discriminator.""" - if isinstance(v, dict): - asset_type: str = v.get("asset_type", "") - else: - asset_type = getattr(v, "asset_type", "") - return asset_type if asset_type in _KNOWN_GROUP_ASSET_TYPES else "_unknown" - - -class UnknownFormatAsset(_BaseIndividualAsset): - """Fallback arm for individual asset_type values not in the SDK's known set. - - When the AdCP protocol adds a new asset_type before the SDK is updated, - responses containing that type parse successfully as UnknownFormatAsset - instead of raising ValidationError for the entire list_creative_formats - response. Structural fields (asset_id, required) are still validated; - type-specific fields are preserved in __pydantic_extra__. - - Access extra wire fields via ``asset.__pydantic_extra__ or {}``. - - This type is read-path only. Do not use it in creative manifests or - emit-side requests — the request path keeps strict Literal validation. - """ - - # extra='allow' is intentionally hardcoded, not inherited from the - # ADCP_STRICT_VALIDATION env-var policy on AdCPBaseModel. The whole - # purpose of this fallback arm is to preserve unknown fields from the wire - # rather than drop or reject them — both behaviors defeat the goal. - model_config = ConfigDict(extra="allow") - asset_type: str - - -class UnknownGroupAsset(_BaseGroupAsset): - """Fallback arm for group asset_type values not in the SDK's known set. - - Same forward-compat guarantee as UnknownFormatAsset but for assets nested - inside a RepeatableAssetGroup (Assets94.assets). Access extra wire fields - via ``asset.__pydantic_extra__ or {}``. - """ - - model_config = ConfigDict(extra="allow") - asset_type: str - - -FormatAssetUnion = _Annotated[ - _Annotated[_ImageFormatAssetInternal, Tag("image")] - | _Annotated[_VideoFormatAssetInternal, Tag("video")] - | _Annotated[_AudioFormatAssetInternal, Tag("audio")] - | _Annotated[_TextFormatAssetInternal, Tag("text")] - | _Annotated[_MarkdownFormatAssetInternal, Tag("markdown")] - | _Annotated[_HtmlFormatAssetInternal, Tag("html")] - | _Annotated[_CssFormatAssetInternal, Tag("css")] - | _Annotated[_JavascriptFormatAssetInternal, Tag("javascript")] - | _Annotated[_VastFormatAssetInternal, Tag("vast")] - | _Annotated[_DaastFormatAssetInternal, Tag("daast")] - | _Annotated[_UrlFormatAssetInternal, Tag("url")] - | _Annotated[_WebhookFormatAssetInternal, Tag("webhook")] - | _Annotated[_BriefFormatAssetInternal, Tag("brief")] - | _Annotated[_CatalogFormatAssetInternal, Tag("catalog")] - | _Annotated[_RepeatableAssetGroupInternal, Tag("repeatable_group")] - | _Annotated[UnknownFormatAsset, Tag("_unknown")], - Discriminator(_format_asset_discriminator), -] -"""Open discriminated union for Format.assets. - -Replaces the generated closed union to add UnknownFormatAsset as a fallback -arm. Applied to Format.assets via _forward_compat._apply_forward_compat(). -""" - -GroupFormatAssetUnion = _Annotated[ - _Annotated[_ImageFormatGroupAssetInternal, Tag("image")] - | _Annotated[_VideoFormatGroupAssetInternal, Tag("video")] - | _Annotated[_AudioFormatGroupAssetInternal, Tag("audio")] - | _Annotated[_TextFormatGroupAssetInternal, Tag("text")] - | _Annotated[_MarkdownFormatGroupAssetInternal, Tag("markdown")] - | _Annotated[_HtmlFormatGroupAssetInternal, Tag("html")] - | _Annotated[_CssFormatGroupAssetInternal, Tag("css")] - | _Annotated[_JavascriptFormatGroupAssetInternal, Tag("javascript")] - | _Annotated[_VastFormatGroupAssetInternal, Tag("vast")] - | _Annotated[_DaastFormatGroupAssetInternal, Tag("daast")] - | _Annotated[_UrlFormatGroupAssetInternal, Tag("url")] - | _Annotated[_WebhookFormatGroupAssetInternal, Tag("webhook")] - | _Annotated[UnknownGroupAsset, Tag("_unknown")], - Discriminator(_group_asset_discriminator), -] -"""Open discriminated union for Assets94.assets (RepeatableAssetGroup slots). +# approach for open discriminated unions. The unions are now GENERATED into +# core/format.py by _OPEN_ASSET_UNIONS in scripts/post_generate_fixes.py, and +# Format.assets / Assets29.assets carry them by generation, so these are +# re-exports rather than definitions. +# +# The vocabulary is why that matters. It was two hand-written frozensets here -- +# 14 individual and 12 group members -- beside a generated union of 21 and 21, +# so seven individual asset types the PINNED schema declares (zip, display_tag, +# published_post, card, pixel_tracker, vast_tracker, daast_tracker) and nine +# group types parsed as the unknown branch, with their generated classes unused. +# The generator knows every branch's asset_type literal because it emitted it. +# See salesagent-d2wuv. -Applied to Assets94.assets via _forward_compat._apply_forward_compat(). -""" +from adcp.types.domains.core.format import ( + FormatAssetUnion, + GroupFormatAssetUnion, + UnknownFormatAsset, + UnknownGroupAsset, +) # ============================================================================ # CROSS-MODULE NAME COLLISION ALIASES (#911, Step 2) @@ -2695,10 +2571,11 @@ class UnknownGroupAsset(_BaseGroupAsset): "PrincipalReadResult", "PrincipalSyncResult", # Creative format asset slot aliases (item_type='individual') - # Forward-compat fallback arms (novel asset_type values parse as these) + # Forward-compat fallback branches (novel asset_type values parse as these) "UnknownFormatAsset", "UnknownGroupAsset", - # Open union types (Format.assets / Assets94.assets after _forward_compat patch) + # Open union types, generated into core/format.py and carried by Format.assets + # / Assets29.assets by generation (_OPEN_ASSET_UNIONS in post_generate_fixes.py) "FormatAssetUnion", "GroupFormatAssetUnion", "ImageFormatAsset", diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 96014822d..8ce298f41 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -77,7 +77,17 @@ import re from collections.abc import Callable, Iterable, Sequence from functools import lru_cache -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Protocol, TypeVar, cast, get_args +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + ClassVar, + Protocol, + TypeAlias, + TypeVar, + cast, + get_args, +) from pydantic import ( AfterValidator, @@ -85,11 +95,13 @@ ConfigDict, Field, GetJsonSchemaHandler, + ModelWrapValidatorHandler, PlainSerializer, PrivateAttr, SerializerFunctionWrapHandler, WithJsonSchema, WrapSerializer, + WrapValidator, field_validator, model_serializer, model_validator, @@ -97,16 +109,44 @@ from pydantic.json_schema import GenerateJsonSchema from pydantic_core import CoreSchema +from adcp.types import _conditions from adcp.types._product_format_declaration import ( PRODUCT_FORMAT_DECLARATION_SCHEMA, check_declaration_rules, ) +from adcp.types._targeting_compat import ( + TargetingOverlayMutationInMaterializedJson, + mutation_overlay_field, +) from adcp.types.base import AdCPBaseModel from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind -from adcp.types.domains.core.creative_asset import CreativeAsset as _CanonicalCreativeWire +from adcp.types.domains.core.creative_asset import ( + # The canonical asset IS the root's canonical-format-kind branch: that branch + # requires ``format_kind`` and types the deprecated ``format_id`` as + # ``None``, which is precisely what this layer used to add by hand. + CreativeAssetV2CreativeCanonicalFormatKind as _CanonicalCreativeWire, +) from adcp.types.domains.core.creative_filters import CreativeFilters as _LegacyCreativeFilters from adcp.types.domains.core.creative_manifest import ( - CreativeManifest as _CanonicalCreativeManifestWire, + # The ROOT, not its canonical branch. core/creative-manifest.json requires + # only ['assets'] and then states a true XOR -- oneOf[0] 'Named-format + # manifest' requires format_id with not:{required:[format_kind]}, oneOf[1] + # 'Canonical-format manifest' requires format_kind with + # not:{required:[format_id]} -- so the root requires NEITHER key. Binding the + # canonical branch imported that branch's required ``format_kind`` onto the + # public name, which is the requiredness regression on this branch + # (salesagent-8qela.69): optional upstream, required here. + CreativeManifest as _CreativeManifestRoot, +) +from adcp.types.domains.core.creative_manifest import ( + # The readback clone stays on the BRANCH, deliberately. Binding it to the root + # would make it validate as the root's UNION, and a ``_DeliveryCreativeManifest`` + # INSTANCE is then an instance of neither branch class -- measured: + # ``test_delivery_manifest_cannot_bypass_strict_input`` went red with + # ``type='model_type'`` against both branches. Upstream could bind the root + # because upstream's root carried no BranchDispatchMixin and so was not a union. + # That is the mixin contract question (salesagent-8qela.82), not this change. + CreativeManifestCanonicalFormatManifest as _CanonicalCreativeManifestWire, ) from adcp.types.domains.core.creative_variant import CreativeVariant as _LegacyCreativeVariant from adcp.types.domains.core.macro_resolution_capability import MacroProcessingCapability @@ -137,7 +177,19 @@ ) from adcp.types.domains.enums.channels import MediaChannel from adcp.types.domains.media_buy.create_media_buy_request import ( - CreateMediaBuyRequest as _LegacyCreateMediaBuyRequest, + # The generated base and one class per branch. The root's branches partition + # by requiredness, so there is no single wire class to refine: two branches + # require ``packages`` and the third forbids it. + CreateMediaBuyRequest as _LegacyCreateMediaBuyBase, +) +from adcp.types.domains.media_buy.create_media_buy_request import ( + CreateMediaBuyRequestCommittedProposalExecution as _LegacyCreateMediaBuyProposal, +) +from adcp.types.domains.media_buy.create_media_buy_request import ( + CreateMediaBuyRequestExplicitPackagesWithFixedAllocation as _LegacyCreateMediaBuyFixed, +) +from adcp.types.domains.media_buy.create_media_buy_request import ( + CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation as _LegacyCMBSellerOptimized, ) from adcp.types.domains.media_buy.create_media_buy_response import ( CreateMediaBuyResponse1 as _LegacyCreateMediaBuyResponse1, @@ -542,6 +594,26 @@ def _legacy_creative_identity_path( return None +def _shape_forbids_top_level_key(cls: type, found: str) -> bool: + """Does *cls*'s own shape already forbid the top-level key *found* names? + + ``found`` is a path from :func:`_legacy_creative_identity_path`, so a + top-level key is exactly ``$.<key>`` -- anything deeper carries a further + ``.`` or a ``[``, and only the path-reading refusal answers for those. + + ``_ABSENT`` is the generated declaration of the keys a shape forbids, + compiled from the schema's branch and inherited by a canonical model that + binds that branch. It is read rather than re-derived from field types: the + canonical layer DELETES a removed field from its own ``model_fields``, so the + resolved-annotation route cannot see it on this class at all. + """ + + key = found[2:] if found.startswith("$.") else "" + if not key or "." in key or "[" in key: + return False + return key in getattr(cls, "_ABSENT", frozenset()) + + def _looks_like_legacy_format_tuple(schema: dict[str, Any], properties: dict[str, Any]) -> bool: keys = set(properties) title = str(schema.get("title", "")).lower() @@ -678,19 +750,78 @@ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: if removed: cls.model_rebuild(force=True) - @model_validator(mode="before") + @model_validator(mode="wrap") @classmethod - def _reject_legacy_creative_identity(cls, value: Any) -> Any: + def _reject_legacy_creative_identity( + cls, value: Any, handler: ModelWrapValidatorHandler[Any] + ) -> Any: + """Refuse a removed creative-identity key, naming the migration. + + WRAP rather than BEFORE, and the kind is what makes this reachable. A + generated branch that FORBIDS the same key refuses it too, through + ``_c.absent``, with the generic sentence every forbidden key gets: + "format_id is not accepted by this request shape". That sentence states + the refusal and not the remedy, and a buyer told a field is unusable + needs to be told what to use instead. + + Both were ``mode="before"``, and the generated one won. Measured: a + same-named override keeps the slot where the name was FIRST collected + walking the MRO, so declaring this on the most derived class does not + move it -- the generated validator still ran first and this one never + saw the key. A wrap validator wraps the whole inner validation, + including every before-validator a subclass declares, so precedence + here comes from the validator's KIND and not from a collection order + nobody controls. Measured on both: wrap-on-the-base wins, and keeps + winning through a further refinement. + + The two refusals are not redundant and neither is deleted. This one + reads a PATH, so it catches a legacy identity nested anywhere in the + document; ``_ABSENT`` is top-level keys only. The generic refusal still + answers for every other key a shape forbids. + + BUT IT DOES NOT ANSWER FOR A TOP-LEVEL KEY THIS SHAPE ITSELF FORBIDS, + and the paragraph above is why that had to change. Where both apply the + two refusals ARE redundant, and preempting the shape's own one costs the + buyer the only thing either refusal delivers on the wire: + + canonical CreativeAsset, this validator winning + issues[0].keyword 'validation' -- NOT a JSON Schema keyword + issues[0].message 'a declared constraint on this field rejected + the value' + the shape's own refusal + issues[0].keyword 'not' -- a Draft-7 keyword + issues[0].message 'not composition failed' + + The remedy sentence below reaches NEITHER envelope. It is stripped on + purpose: ``_OPAQUE_TYPES`` in ``adcp.validation.pydantic_errors`` replaces + the message of any validator-raised ``ValueError``, because this SDK's + validators interpolate caller values into those sentences. So the + justification in the first paragraph -- "a buyer told a field is unusable + needs to be told what to use instead" -- is unsatisfied by construction + (salesagent-8qela.34), and what the preemption actually trades is a + vocabulary keyword for an invented one, against + ``schemas/cache/3.2/core/error.json``, which requires ``issues[].keyword`` + and says it is "drawn from the JSON Schema vocabulary ... do not invent + custom values here". + + So the shape's refusal OWNS a top-level key the shape forbids. Measured + over all fifteen (canonical model, removed name) pairs: exactly one -- + ``CreativeAsset.format_id`` -- has a shape that also forbids the name, so + the other fourteen are unaffected and still get the path-reading refusal + below. Keeping the remedy AND the keyword needs this validator to raise a + structured error instead of a bare ``ValueError``; that is + salesagent-8qela.106 and is deliberately not done here. + """ found = _legacy_creative_identity_path( value, allow_root_v1_ref=cls.__adcp_format_declaration_scope__, format_scope=cls.__adcp_format_declaration_scope__, ) - if found is not None: + if found is not None and not _shape_forbids_top_level_key(cls, found): raise ValueError( f"{found} contains legacy creative identity; use an explicit Legacy* model" ) - return value + return handler(value) def model_dump(self, **kwargs: Any) -> dict[str, Any]: kwargs.setdefault("serialize_as_any", False) @@ -918,8 +1049,18 @@ class Product(_LegacyProduct, CanonicalBoundaryModel): if TYPE_CHECKING: # the removed field, hidden from the constructor too format_ids: _RemovedFormatIds = Field(default=None, init=False) - format_options: list[Format] = Field( # type: ignore[assignment] - min_length=1, description="Canonical creative formats accepted by this product." + # Optional, because core/product.json requires it in no branch unconditionally: + # the root's ``required`` is [product_id, name, description, publisher_properties, + # delivery_type, pricing_options, reporting_capabilities], and ``format_options`` + # is required only inside the ``anyOf`` branch "Canonical-format product". An + # anyOf branch's requirement is not the root's, so narrowing the ITEM type here + # is right and dropping ``| None`` was not -- it made the other branch + # unconstructible. ``placements`` two lines down is the same narrowing done + # correctly, and ``pricing_options`` is required because the ROOT requires it. + format_options: list[Format] | None = Field( # type: ignore[assignment] + default=None, + min_length=1, + description="Canonical creative formats accepted by this product.", ) placements: list[Placement] | None = Field(default=None, min_length=1) # type: ignore[assignment] pricing_options: list[CanonicalPricingOption] = Field(min_length=1) @@ -936,9 +1077,31 @@ class CreativeAsset(_CanonicalCreativeWire, CanonicalBoundaryModel): """ if TYPE_CHECKING: # the removed field, hidden from the constructor too - format_id: _RemovedFormatId = Field(default=None, init=False) - - format_kind: str + # Annotated ``None``, not ``_RemovedFormatId``. This class binds the + # canonical-format BRANCH, which types ``format_id`` as ``None``; + # ``_RemovedFormatId`` is ``FormatReferenceStructuredObject | None`` and so + # would WIDEN the parent, which mypy refuses and which pyright names as an + # override error. A redeclaration restates the parent's annotation -- here + # that is ``None`` -- and ``init=False`` is what removes the keyword from the + # ``__init__`` both checkers synthesize through ``dataclass_transform``. + # + # ``None`` is the only spelling available here and it is the right one. mypy + # refuses the alternative outright -- 'NoneType should not be used as a type, + # please use None instead [nonetype-type]' -- so a gate that wanted + # ``NoneType`` written in the source would have demanded a different spelling + # of the same annotation than the type checker allows. + # test_the_removed_field_is_hidden_from_the_type_checker therefore compares the + # two sides as pydantic RESOLVES them rather than as they are written: both go + # through a throwaway field, which is where ``None`` becomes ``NoneType``. + # See salesagent-8qela.70. + format_id: None = Field(default=None, init=False) + + # Nothing is declared here. The branch this subclasses requires ``format_kind`` + # and types ``format_id`` as ``None``, so the narrowing and the removal this + # class used to spell by hand are the branch's own shape -- and the ``None`` + # annotation is stronger than the hidden-from-the-constructor trick it + # replaces, because it is a type error at every call rather than only the + # ones a type checker sees through ``init=False``. class Creative(_CanonicalListedCreative, CanonicalBoundaryModel): @@ -955,9 +1118,25 @@ class Creative(_CanonicalListedCreative, CanonicalBoundaryModel): format_kind: str -class CreativeManifest(_CanonicalCreativeManifestWire, CanonicalBoundaryModel): +class CreativeManifest(_CreativeManifestRoot, CanonicalBoundaryModel): """Canonical manifest accepting the SDK's public standalone asset models. + Bound to the generated ROOT, which is what the public name means: the root + admits either branch and requires neither key. The branch classes stay + reachable as branches and nothing about them changes. + + Safe only because salesagent-8qela.76 landed first. The root's XOR lives in + its core schema, which a subclass does not inherit, so rebinding alone used + to make this class ACCEPT a manifest carrying neither key -- a document the + pin refuses. With the compiled check now on the shared base the subclass + inherits it, and the measured table is: canonical ACCEPTS, neither REFUSES, + both REFUSES. + + ``format_id`` is still removed at runtime by + ``CanonicalBoundaryModel.__pydantic_init_subclass__``, so the deprecated + named-format branch remains unconstructible here. That is INHERITED, true at + upstream too, and tracked as salesagent-8qela.70 -- deliberately not bundled. + The 3.2 aggregate asset-union schema currently generates structurally duplicate Pydantic classes. Convert public ``ImageContent``/``UrlContent`` (and peers) back to their wire dictionaries before the aggregate union @@ -968,6 +1147,8 @@ class CreativeManifest(_CanonicalCreativeManifestWire, CanonicalBoundaryModel): if TYPE_CHECKING: # the removed field, hidden from the constructor too format_id: _RemovedFormatId = Field(default=None, init=False) + # ``format_id`` is typed ``None`` by the branch; see :class:`CreativeAsset`. + @model_validator(mode="before") @classmethod def _normalize_standalone_assets(cls, data: Any) -> Any: @@ -1062,9 +1243,7 @@ class DeliveryCreative(_LegacyDeliveryCreative, CanonicalBoundaryModel): if TYPE_CHECKING: # the removed field, hidden from the constructor too format_id: _RemovedFormatId = Field(default=None, init=False) - variants: list[_DeliveryCreativeVariant] = _inherit( # type: ignore[assignment] - _LegacyDeliveryCreative, "variants" - ) + variants: list[_DeliveryCreativeVariant] = _inherit(_LegacyDeliveryCreative, "variants") class CreativeFilters(_LegacyCreativeFilters, CanonicalBoundaryModel): @@ -1088,6 +1267,13 @@ class PackageRequest(_LegacyPackageRequest, CanonicalBoundaryModel): format_ids: _RemovedFormatIds = Field(default=None, init=False) creatives: list[CreativeAsset] | None = Field(default=None, min_length=1) + # The generated parent carries ``TargetingOverlayMutation``. A canonical model + # materializes JSON in its before validator, so this subtree re-enters core JSON + # validation to keep native error codes and extra/missing order -- which is the + # only difference between the two aliases. + targeting_overlay: TargetingOverlayMutationInMaterializedJson = mutation_overlay_field( + _LegacyPackageRequest + ) @model_validator(mode="after") def _validate_format_params(self) -> PackageRequest: @@ -1103,6 +1289,10 @@ class PackageUpdate(_LegacyPackageUpdate, CanonicalBoundaryModel): """Canonical package update; creatives are canonical assets.""" creatives: list[CreativeAsset] | None = Field(default=None, min_length=1) # type: ignore[assignment] + # See PackageRequest.targeting_overlay above. + targeting_overlay: TargetingOverlayMutationInMaterializedJson = mutation_overlay_field( + _LegacyPackageUpdate + ) class Package(_LegacyPackage, CanonicalBoundaryModel): @@ -1137,10 +1327,101 @@ class GetProductsResponse(_LegacyGetProductsResponse, CanonicalBoundaryModel): products: list[Product] | None = None # type: ignore[assignment] -class CreateMediaBuyRequest(_LegacyCreateMediaBuyRequest, CanonicalBoundaryModel): - """Canonical create request; packages are canonical package requests.""" +class CreateMediaBuyRequest(_LegacyCreateMediaBuyBase, CanonicalBoundaryModel): + """Canonical create request; packages are canonical package requests. + + This class carries the branches' core schema, so parsing it -- by name, + nested in another model, or through a ``TypeAdapter`` -- returns the + canonical branch the document satisfies. + + ``packages`` is narrowed at the BASE's requiredness, not at a branch's. A + base cannot carry one requiredness for a field whose requiredness is what + distinguishes the branches, so each branch re-declares the narrowing at its + own. The generated branch is listed FIRST in each branch's bases, so the + branch's requiredness wins by MRO position, and ``CanonicalBoundaryModel`` + is listed LAST so ``model_config`` resolves from the canonical side. + """ + + packages: Sequence[PackageRequest] | None = None + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # Re-declared, which is what moves the dispatch from the GENERATED + # branches to the canonical ones: the shared hook asks whether a class + # declares this method, so the generated base of the same name keeps its + # own branches and this one gets these. Inheriting it instead left the + # method reporting the generated branches while the schema was built + # from the canonical ones -- two answers to one question. + return ( + CreateMediaBuyRequestExplicitPackagesWithFixedAllocation, + CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation, + CreateMediaBuyRequestCommittedProposalExecution, + ) + + @classmethod + def _branch_combinator(cls) -> str: + return "anyOf" + + +class CreateMediaBuyRequestExplicitPackagesWithFixedAllocation( + _LegacyCreateMediaBuyFixed, CreateMediaBuyRequest +): + """Canonical fixed-allocation create request; packages are canonical. + + ``packages`` is re-declared to carry the CANONICAL item type. Its + requiredness comes from the generated branch by MRO position, not from this + line -- dropping the line keeps the field required and loses the narrowing. + """ + + packages: Sequence[PackageRequest] + + +class CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation( + _LegacyCMBSellerOptimized, CreateMediaBuyRequest +): + """Canonical seller-optimized create request; packages are canonical.""" + + packages: Sequence[PackageRequest] + + +class CreateMediaBuyRequestCommittedProposalExecution( + _LegacyCreateMediaBuyProposal, CreateMediaBuyRequest +): + """Canonical proposal-execution create request. + + ``packages`` is re-declared as ``None`` because this branch FORBIDS it. The + canonical base narrows the field for the branches that permit it, and that + narrowing and this ban are incompatible declarations inherited from two + bases -- so the ban is restated here, where it is true, rather than silenced. + Re-declaring it as a canonical list would re-widen what the branch bans. + """ - packages: list[PackageRequest] | None = None + packages: None = None + + +#: The canonical create request's branches. Annotate this to make a type checker +#: force exhaustive narrowing; annotate CreateMediaBuyRequest for the same +#: strictness at runtime under one name. +CreateMediaBuyRequestBranches: TypeAlias = Annotated[ + CreateMediaBuyRequestExplicitPackagesWithFixedAllocation + | CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation + | CreateMediaBuyRequestCommittedProposalExecution, + WrapValidator(_conditions.name_the_combinator("anyOf", CreateMediaBuyRequest._branch_models())), +] + +# No module-level ``CreateMediaBuyRequestBranchesAdapter``: a ``TypeAdapter`` +# builds its validator AT IMPORT, so it captures schema state once and shares it +# with every test in the process, which is what +# ``test_sdk_has_no_module_level_type_adapter_construction`` forbids. This file +# is hand-written and carried the same declaration the generator emitted; both +# are gone. ``CreateMediaBuyRequestBranches`` above is the public name and stays +# reachable, and a caller who wants an adapter writes +# ``TypeAdapter(CreateMediaBuyRequestBranches)``. + +# CreateMediaBuyRequest is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild installs +# the branches' schema -- the same two-step every other root with branches uses. +CreateMediaBuyRequest.model_rebuild(force=True) class UpdateMediaBuyRequest(_LegacyUpdateMediaBuyRequest, CanonicalBoundaryModel): @@ -1174,11 +1455,11 @@ def _normalize_legacy_status(cls, data: Any) -> Any: class CreateMediaBuyResponse2(_LegacyCreateMediaBuyResponse2, CanonicalBoundaryModel): - """Canonical create-media-buy error arm.""" + """Canonical create-media-buy error branch.""" class CreateMediaBuyResponse3(_LegacyCreateMediaBuyResponse3, CanonicalBoundaryModel): - """Canonical create-media-buy submitted arm.""" + """Canonical create-media-buy submitted branch.""" CreateMediaBuyResponse = CreateMediaBuyResponse1 | CreateMediaBuyResponse2 | CreateMediaBuyResponse3 @@ -1206,11 +1487,11 @@ def _normalize_legacy_status(cls, data: Any) -> Any: class UpdateMediaBuyResponse2(_LegacyUpdateMediaBuyResponse2, CanonicalBoundaryModel): - """Canonical update-media-buy error arm.""" + """Canonical update-media-buy error branch.""" class UpdateMediaBuyResponse3(_LegacyUpdateMediaBuyResponse3, CanonicalBoundaryModel): - """Canonical update-media-buy submitted arm.""" + """Canonical update-media-buy submitted branch.""" UpdateMediaBuyResponse = UpdateMediaBuyResponse1 | UpdateMediaBuyResponse2 | UpdateMediaBuyResponse3 @@ -1219,7 +1500,16 @@ class UpdateMediaBuyResponse3(_LegacyUpdateMediaBuyResponse3, CanonicalBoundaryM class SyncCreativesRequest(_LegacySyncCreativesRequest, CanonicalBoundaryModel): """Canonical creative sync request; creatives are canonical assets.""" - creatives: list[CreativeAsset] = Field(min_length=1) # type: ignore[assignment] + # Optional, matching ``PackageRequest.creatives`` above and the generated parent: + # creative/sync-creatives-request.json requires only [idempotency_key, account] at + # the root and ``creatives`` only inside an untitled ``anyOf`` branch. Requiring it + # here made the SDK refuse a schema-valid sync_creatives request that takes the + # other branch -- a request model, so this was a refusal and not a typing + # inconvenience. Narrowing the item type to the canonical asset is the part that + # was correct. + creatives: list[CreativeAsset] | None = Field( # type: ignore[assignment] + default=None, min_length=1 + ) class ListCreativesRequest(_LegacyListCreativesRequest, CanonicalBoundaryModel): @@ -1292,7 +1582,12 @@ class GetCreativeDeliveryResponse(_LegacyGetCreativeDeliveryResponse, CanonicalB Package, GetProductsRequest, GetProductsResponse, - CreateMediaBuyRequest, + # One entry per branch: the create request is a union now, and each branch is a + # canonical model in its own right -- every property this tuple is graded + # for has to hold of each of them, not of the alias over them. + CreateMediaBuyRequestExplicitPackagesWithFixedAllocation, + CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation, + CreateMediaBuyRequestCommittedProposalExecution, CreateMediaBuyResponse1, CreateMediaBuyResponse2, CreateMediaBuyResponse3, @@ -1314,6 +1609,10 @@ class GetCreativeDeliveryResponse(_LegacyGetCreativeDeliveryResponse, CanonicalB __all__ = [ "CanonicalBoundaryModel", "CreateMediaBuyRequest", + "CreateMediaBuyRequestBranches", + "CreateMediaBuyRequestCommittedProposalExecution", + "CreateMediaBuyRequestExplicitPackagesWithFixedAllocation", + "CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation", "CreateMediaBuyResponse", "CreateMediaBuyResponse1", "CreateMediaBuyResponse2", diff --git a/src/adcp/types/capabilities.py b/src/adcp/types/capabilities.py index 1c4a2b487..74b3260f7 100644 --- a/src/adcp/types/capabilities.py +++ b/src/adcp/types/capabilities.py @@ -12,7 +12,7 @@ This module sits in the import-architecture whitelist for direct generated-tree imports, alongside the other re-export layers -(``aliases.py``, ``_ergonomic.py``, ``_forward_compat.py``, ``_generated.py``, +(``aliases.py``, ``_forward_compat.py``, ``_generated.py``, ``error_details.py`` and the ``domains/`` modules); see ``ALLOWED_FILES`` in ``tests/test_import_layering.py`` for the current set. It pulls the capabilities sub-models out under disambiguated names, so the colliding three don't shadow the @@ -150,7 +150,17 @@ class MediaBuy(_MediaBuy): - """Media-buy capabilities using the canonical reporting model identity.""" + """Media-buy capabilities using the canonical reporting model identity. + + This subclass is why ``acceptance_policy_discovery`` has to be declared on + the generated field rather than patched onto the generated class. A + subclass fixes its field set at class creation, so a later patch to + ``_MediaBuy.model_fields`` plus ``_MediaBuy.model_rebuild(force=True)`` + never reached here: ``tests/test_composability_invariant.py`` measured this + class still demanding the bundled clone while the parent had the canonical + one. ``COMPATIBILITY_ANNOTATIONS`` in ``scripts/post_generate_fixes.py`` + writes it at the parent's declaration, which a subclass inherits. + """ reporting_delivery: ReportingDeliveryCapabilities | None = None # type: ignore[assignment] diff --git a/src/adcp/types/coercion.py b/src/adcp/types/coercion.py index 97eac35b1..d7a04f254 100644 --- a/src/adcp/types/coercion.py +++ b/src/adcp/types/coercion.py @@ -11,6 +11,17 @@ # Dict coercion for context: ListCreativeFormatsRequest(context={"key": "value"}) ListCreativeFormatsRequest(context=ContextObject(key="value")) + +What the generated tree actually wires, as of salesagent-1jj5e: only +``coerce_to_unique_enum_list``, on the four disclosure-filter fields, declared on +the field by ``COMPATIBILITY_ANNOTATIONS`` in ``scripts/post_generate_fixes.py``. +Both examples above hold WITHOUT a validator -- a generated enum is a +``StrEnum``, so pydantic accepts the string, and it builds a model field from a +dict on its own. ``_ergonomic.py`` applied the other four helpers to 70 fields +and every one of those patches was inert; measured, the validation surface of all +14 models it named was identical with and without it. The other helpers are kept +as a public utility for an adopter's own ``Annotated``, not because the SDK's own +models need them. See salesagent-5beb4. """ from __future__ import annotations diff --git a/src/adcp/types/domains/a2ui/__init__.py b/src/adcp/types/domains/a2ui/__init__.py index 6645b8324..7f168b9f4 100644 --- a/src/adcp/types/domains/a2ui/__init__.py +++ b/src/adcp/types/domains/a2ui/__init__.py @@ -9,48 +9,126 @@ it from its own schema's module, ``adcp.types.domains.a2ui.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.a2ui.bound_value import ( - A2UiBoundValue, - A2UiBoundValue1, - A2UiBoundValue2, - A2UiBoundValue3, - A2UiBoundValue4, - A2UiBoundValue5, -) -from adcp.types.domains.a2ui.component import A2UiComponent -from adcp.types.domains.a2ui.si_catalog import ( - Action23, - Align, - AppHandoff, - Apps, - Button, - Card, - Column, - Component, - Image, - IntegrationAction, - Justify, - Layout, - Link, - List, - ProductCard, - Row, - SiComponentCatalog, - Template, - Text, - Type, - Variant, - Variant4, -) -from adcp.types.domains.a2ui.surface import A2UiSurface -from adcp.types.domains.a2ui.user_action import A2UiUserAction +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.a2ui.bound_value import ( + A2UiBoundValue, + A2UiBoundValue1, + A2UiBoundValue2, + A2UiBoundValue3, + A2UiBoundValue4, + A2UiBoundValue5, + ) + from adcp.types.domains.a2ui.component import A2UiComponent + from adcp.types.domains.a2ui.si_catalog import ( + Action23, + Align, + AppHandoff, + Apps, + Button, + Card, + Column, + Component, + Image, + IntegrationAction, + Justify, + Layout, + Link, + List, + ProductCard, + Row, + SiComponentCatalog, + Template, + Text, + Type, + Variant, + Variant4, + ) + from adcp.types.domains.a2ui.surface import A2UiSurface + from adcp.types.domains.a2ui.user_action import A2UiUserAction + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "a2ui.bound_value": ( + "A2UiBoundValue", + "A2UiBoundValue1", + "A2UiBoundValue2", + "A2UiBoundValue3", + "A2UiBoundValue4", + "A2UiBoundValue5", + ), + "a2ui.component": ("A2UiComponent",), + "a2ui.si_catalog": ( + "Action23", + "Align", + "AppHandoff", + "Apps", + "Button", + "Card", + "Column", + "Component", + "Image", + "IntegrationAction", + "Justify", + "Layout", + "Link", + "List", + "ProductCard", + "Row", + "SiComponentCatalog", + "Template", + "Text", + "Type", + "Variant", + "Variant4", + ), + "a2ui.surface": ("A2UiSurface",), + "a2ui.user_action": ("A2UiUserAction",), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/aao/__init__.py b/src/adcp/types/domains/aao/__init__.py index d08486588..6856c797b 100644 --- a/src/adcp/types/domains/aao/__init__.py +++ b/src/adcp/types/domains/aao/__init__.py @@ -9,19 +9,68 @@ it from its own schema's module, ``adcp.types.domains.aao.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.aao.agent_publishers import ( - AaoDirectoryAgentPublishersInverseLookupResponse, - DiscoveryMethod, - PublisherEntry, - Status, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.aao.agent_publishers import ( + AaoDirectoryAgentPublishersInverseLookupResponse, + DiscoveryMethod, + PublisherEntry, + Status, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "aao.agent_publishers": ( + "AaoDirectoryAgentPublishersInverseLookupResponse", + "DiscoveryMethod", + "PublisherEntry", + "Status", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/account/__init__.py b/src/adcp/types/domains/account/__init__.py index 0d3483af0..edcd882cf 100644 --- a/src/adcp/types/domains/account/__init__.py +++ b/src/adcp/types/domains/account/__init__.py @@ -9,56 +9,159 @@ it from its own schema's module, ``adcp.types.domains.account.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.account.get_account_financials_request import GetAccountFinancialsRequest -from adcp.types.domains.account.get_account_financials_response import ( - Balance, - Credit, - GetAccountFinancialsResponse, - GetAccountFinancialsResponse1, - GetAccountFinancialsResponse2, - Invoice, - LastTopUp, - Spend, -) -from adcp.types.domains.account.list_account_changes_request import ( - ListAccountChangesRequest, - StartingPosition, -) -from adcp.types.domains.account.list_account_changes_response import ( - Kind, - ListAccountChangesResponse, - SourceCoverageItem, - Status22, -) -from adcp.types.domains.account.list_accounts_request import ListAccountsRequest -from adcp.types.domains.account.list_accounts_response import ListAccountsResponse -from adcp.types.domains.account.report_usage_request import ReportUsageRequest, UsageItem -from adcp.types.domains.account.report_usage_response import ReportUsageResponse -from adcp.types.domains.account.sync_accounts_request import ( - Accounts, - Accounts1, - SyncAccountsRequest, -) -from adcp.types.domains.account.sync_accounts_response import ( - CreditLimit, - Setup, - SyncAccountsResponse, - SyncAccountsResponse1, - SyncAccountsResponse2, -) -from adcp.types.domains.account.sync_governance_request import Authentication, SyncGovernanceRequest -from adcp.types.domains.account.sync_governance_response import ( - SyncGovernanceResponse, - SyncGovernanceResponse1, - SyncGovernanceResponse2, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.account.get_account_financials_request import ( + GetAccountFinancialsRequest, + ) + from adcp.types.domains.account.get_account_financials_response import ( + Balance, + Credit, + GetAccountFinancialsResponse, + GetAccountFinancialsResponse1, + GetAccountFinancialsResponse2, + Invoice, + LastTopUp, + Spend, + ) + from adcp.types.domains.account.list_account_changes_request import ( + ListAccountChangesRequest, + StartingPosition, + ) + from adcp.types.domains.account.list_account_changes_response import ( + Kind, + ListAccountChangesResponse, + ListAccountChangesResponseBranches, + ListAccountChangesResponseCompleted, + ListAccountChangesResponseFailed, + SourceCoverageItem, + Status22, + ) + from adcp.types.domains.account.list_accounts_request import ListAccountsRequest + from adcp.types.domains.account.list_accounts_response import ListAccountsResponse + from adcp.types.domains.account.report_usage_request import ReportUsageRequest, UsageItem + from adcp.types.domains.account.report_usage_response import ReportUsageResponse + from adcp.types.domains.account.sync_accounts_request import ( + Accounts, + Accounts1, + SyncAccountsRequest, + ) + from adcp.types.domains.account.sync_accounts_response import ( + CreditLimit, + Setup, + SyncAccountsResponse, + SyncAccountsResponse1, + SyncAccountsResponse2, + ) + from adcp.types.domains.account.sync_governance_request import ( + Authentication, + SyncGovernanceRequest, + ) + from adcp.types.domains.account.sync_governance_response import ( + SyncGovernanceResponse, + SyncGovernanceResponse1, + SyncGovernanceResponse2, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "account.get_account_financials_request": ("GetAccountFinancialsRequest",), + "account.get_account_financials_response": ( + "Balance", + "Credit", + "GetAccountFinancialsResponse", + "GetAccountFinancialsResponse1", + "GetAccountFinancialsResponse2", + "Invoice", + "LastTopUp", + "Spend", + ), + "account.list_account_changes_request": ( + "ListAccountChangesRequest", + "StartingPosition", + ), + "account.list_account_changes_response": ( + "Kind", + "ListAccountChangesResponse", + "ListAccountChangesResponseBranches", + "ListAccountChangesResponseCompleted", + "ListAccountChangesResponseFailed", + "SourceCoverageItem", + "Status22", + ), + "account.list_accounts_request": ("ListAccountsRequest",), + "account.list_accounts_response": ("ListAccountsResponse",), + "account.report_usage_request": ( + "ReportUsageRequest", + "UsageItem", + ), + "account.report_usage_response": ("ReportUsageResponse",), + "account.sync_accounts_request": ( + "Accounts", + "Accounts1", + "SyncAccountsRequest", + ), + "account.sync_accounts_response": ( + "CreditLimit", + "Setup", + "SyncAccountsResponse", + "SyncAccountsResponse1", + "SyncAccountsResponse2", + ), + "account.sync_governance_request": ( + "Authentication", + "SyncGovernanceRequest", + ), + "account.sync_governance_response": ( + "SyncGovernanceResponse", + "SyncGovernanceResponse1", + "SyncGovernanceResponse2", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ @@ -77,6 +180,9 @@ "LastTopUp", "ListAccountChangesRequest", "ListAccountChangesResponse", + "ListAccountChangesResponseBranches", + "ListAccountChangesResponseCompleted", + "ListAccountChangesResponseFailed", "ListAccountsRequest", "ListAccountsResponse", "ReportUsageRequest", diff --git a/src/adcp/types/domains/account/list_account_changes_response.py b/src/adcp/types/domains/account/list_account_changes_response.py index e6f932a82..916ced764 100644 --- a/src/adcp/types/domains/account/list_account_changes_response.py +++ b/src/adcp/types/domains/account/list_account_changes_response.py @@ -1,17 +1,16 @@ # generated by datamodel-codegen: # filename: account/list_account_changes_response.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpResponse, SchemaInt from adcp.types._str_enum import StrEnum -from typing import Annotated +from typing import Annotated, Any, ClassVar, Literal, TypeAlias from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, WrapValidator, model_validator from ..core import account_change from ..core import context as context_1 @@ -21,9 +20,16 @@ from ..core.version_envelope import AdcpVersionEnvelope +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + from pydantic_core import PydanticCustomError +from adcp.types._branch_dispatch import BranchDispatchMixin + + class Kind(StrEnum): seller = 'seller' connected_platform = 'connected_platform' @@ -82,7 +88,25 @@ class Status22(StrEnum): failed = 'failed' -class ListAccountChangesResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): +class ListAccountChangesResponse(BranchDispatchMixin, AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): + """What every branch of account/list-account-changes-response.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``ListAccountChangesResponseCompleted`` + ``ListAccountChangesResponseFailed`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + ListAccountChangesResponseBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + model_config = ConfigDict( extra='allow', ) @@ -130,19 +154,139 @@ class ListAccountChangesResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None status: Status22 + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'adcp_error', 'available_since', 'changes', 'cursor', 'errors', 'generated_at', 'has_more', 'status'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'status'}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + ListAccountChangesResponseCompleted, + ListAccountChangesResponseFailed, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' @model_validator(mode='after') def _require_schema_required_group(self) -> ListAccountChangesResponse: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('status', 'changes', 'cursor', 'has_more', 'available_since', 'generated_at'), ('status', 'adcp_error', 'errors'),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'oneOf', - 'ListAccountChangesResponse requires at least one of these field groups: status+changes+cursor+has_more+available_since+generated_at | status+adcp_error+errors', + # Compiled from account/list-account-changes-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # status+changes+cursor+has_more+available_since+generated_at + ( + not (_c.supplied(self, 'adcp_error')) + and _c.descend(self, 'status', lambda status: status == 'completed' and _c.json_type(status, 'string')) + and (_c.supplied(self, 'status') and _c.sent(self, 'changes') and _c.sent(self, 'cursor') and _c.sent(self, 'has_more') and _c.sent(self, 'available_since') and _c.sent(self, 'generated_at')) + ) + # status+adcp_error+errors + or ( + (_c.descend(self, 'errors', lambda errors: _c.length(errors, minimum=1)) and _c.descend(self, 'status', lambda status: status == 'failed' and _c.json_type(status, 'string'))) + and (_c.supplied(self, 'status') and _c.supplied(self, 'adcp_error') and _c.sent(self, 'errors')) + ) + ): + raise PydanticCustomError( + 'oneOf', + 'ListAccountChangesResponse requires at least one of these field groups: status+changes+cursor+has_more+available_since+generated_at | status+adcp_error+errors', + ) + if [(not (_c.supplied(self, 'adcp_error')) and _c.descend(self, 'status', lambda status: status == 'completed' and _c.json_type(status, 'string')) and (_c.supplied(self, 'status') and _c.sent(self, 'changes') and _c.sent(self, 'cursor') and _c.sent(self, 'has_more') and _c.sent(self, 'available_since') and _c.sent(self, 'generated_at'))), ((_c.descend(self, 'errors', lambda errors: _c.length(errors, minimum=1)) and _c.descend(self, 'status', lambda status: status == 'failed' and _c.json_type(status, 'string'))) and (_c.supplied(self, 'status') and _c.supplied(self, 'adcp_error') and _c.sent(self, 'errors')))].count(True) != 1: + raise PydanticCustomError( + 'oneOf', + 'ListAccountChangesResponse satisfies more than one of these mutually exclusive field groups: status+changes+cursor+has_more+available_since+generated_at | status+adcp_error+errors', + ) + return self + +class ListAccountChangesResponseCompleted(ListAccountChangesResponse): + """The oneOf/0 branch. + + One branch of account/list-account-changes-response.json, stated as a shape: oneOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + available_since: Annotated[AwareDatetime, Field(description='Oldest time for which the seller currently retains change records for this account and caller. The capability guarantees at least 90 days after adoption; pre-adoption history is not fabricated.')] + changes: Annotated[list[account_change.AccountChange], Field(description='Matching changes in oldest-first total account order. Timestamps are descriptive and do not define this order.', max_length=100)] + cursor: Annotated[str, Field(description='Opaque checkpoint strictly after the high-water scanned by this page. Always persist this value, including when changes is empty or has_more is false. A filtered empty page still advances past scanned nonmatching records.', max_length=4096, min_length=1)] + generated_at: Annotated[AwareDatetime, Field(description='Seller time when this page and its source-coverage watermarks were generated.')] + has_more: Annotated[StrictBool, Field(description='True when more retained matching changes were available at generation time. False means caught up to seller ingestion, not necessarily to an unavailable or delayed connected source.')] + status: Literal['completed'] + _ABSENT: ClassVar[frozenset[str]] = frozenset({}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({'adcp_error'}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) ) + + +class ListAccountChangesResponseFailed(ListAccountChangesResponse): + """The oneOf/1 branch. + + One branch of account/list-account-changes-response.json, stated as a shape: oneOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + errors: list[error.Error] + status: Literal['failed'] + @model_validator(mode='after') + def _require_schema_required_group(self) -> ListAccountChangesResponseFailed: + # Compiled from account/list-account-changes-response.json oneOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + _c.descend(self, 'errors', lambda errors: _c.length(errors, minimum=1)) + ): + raise PydanticCustomError( + 'oneOf', + 'ListAccountChangesResponseFailed violates what account/list-account-changes-response.json states for its oneOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +#: account/list-account-changes-response.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate ListAccountChangesResponse to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +ListAccountChangesResponseBranches: TypeAlias = Annotated[ + ListAccountChangesResponseCompleted | ListAccountChangesResponseFailed, + WrapValidator( + _c.name_the_combinator('oneOf', ListAccountChangesResponse._branch_models(), ListAccountChangesResponse._ROOT_REQUIRED) + ), +] + +# ListAccountChangesResponse is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +ListAccountChangesResponse.model_rebuild(force=True) diff --git a/src/adcp/types/domains/account/sync_governance_response.py b/src/adcp/types/domains/account/sync_governance_response.py index 01c891b6a..e04cee15c 100644 --- a/src/adcp/types/domains/account/sync_governance_response.py +++ b/src/adcp/types/domains/account/sync_governance_response.py @@ -1,15 +1,13 @@ # generated by datamodel-codegen: # filename: account/sync_governance_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpResponse -from adcp.types.response_dispatch import ResponseArmDispatchMixin - from adcp.types._str_enum import StrEnum -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, ConfigDict, Field @@ -22,6 +20,9 @@ from ..core.version_envelope import AdcpVersionEnvelope +from adcp.types._branch_dispatch import BranchDispatchMixin + + class Status(StrEnum): synced = 'synced' failed = 'failed' @@ -34,16 +35,27 @@ class GovernanceAgent(AdCPBaseModel): url: Annotated[AnyUrl, Field(description='Governance agent endpoint URL.')] -class SyncGovernanceResponse(AdcpResponse, ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): - """Constructible compatibility base for generated response arms.""" +class SyncGovernanceResponse(AdcpResponse, BranchDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response branches.""" + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'status'}) @classmethod - def _response_arm_models(cls) -> tuple[type[SyncGovernanceResponse], ...]: + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. return ( SyncGovernanceResponse1, SyncGovernanceResponse2, ) + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' + class SyncGovernanceResponse2(SyncGovernanceResponse): model_config = ConfigDict( @@ -96,3 +108,6 @@ class SyncGovernanceResponse1(SyncGovernanceResponse): accounts: Annotated[list[Account], Field(description='Per-account sync results')] context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + +SyncGovernanceResponse.model_rebuild(force=True) diff --git a/src/adcp/types/domains/brand/__init__.py b/src/adcp/types/domains/brand/__init__.py index a37d39fdc..2396af0e5 100644 --- a/src/adcp/types/domains/brand/__init__.py +++ b/src/adcp/types/domains/brand/__init__.py @@ -9,109 +9,277 @@ it from its own schema's module, ``adcp.types.domains.brand.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.brand.acquire_rights_request import AcquireRightsRequest, Campaign -from adcp.types.domains.brand.acquire_rights_response import ( - AcquireRightsResponse, - AcquireRightsResponse1, - AcquireRightsResponse2, - AcquireRightsResponse3, - AcquireRightsResponse4, - Disclosure, -) -from adcp.types.domains.brand.creative_approval_request import CreativeApprovalRequest -from adcp.types.domains.brand.creative_approval_response import ( - CreativeApprovalResponse, - CreativeApprovalResponse1, - CreativeApprovalResponse2, - CreativeApprovalResponse3, - CreativeApprovalResponse4, -) -from adcp.types.domains.brand.get_brand_identity_request import Field1, GetBrandIdentityRequest -from adcp.types.domains.brand.get_brand_identity_response import ( - Asset, - Colors, - File, - FontRole2, - Fonts, - GetBrandIdentityResponse, - GetBrandIdentityResponse1, - GetBrandIdentityResponse2, - Tone, - VoiceSynthesis, -) -from adcp.types.domains.brand.get_rights_request import GetRightsRequest -from adcp.types.domains.brand.get_rights_response import ( - Excluded, - ExclusivityStatus, - GetRightsResponse, - GetRightsResponse1, - GetRightsResponse2, - PreviewAsset, - Right, -) -from adcp.types.domains.brand.revocation_notification import RevocationNotification -from adcp.types.domains.brand.rights_pricing_option import RightsPricingOption -from adcp.types.domains.brand.rights_terms import Exclusivity, RightsTerms -from adcp.types.domains.brand.search_brands_request import SearchBrandsRequest -from adcp.types.domains.brand.search_brands_response import ( - Background, - ExcludedCountry, - KellerType, - Orientation, - RelationshipTrust, - SearchBrandResult, - SearchBrandsResponse, - Variant, -) -from adcp.types.domains.brand.update_rights_request import UpdateRightsRequest -from adcp.types.domains.brand.update_rights_response import ( - UpdateRightsResponse, - UpdateRightsResponse1, - UpdateRightsResponse2, -) -from adcp.types.domains.brand.verification_status import VerificationStatus -from adcp.types.domains.brand.verify_brand_claim_request import VerifyBrandClaimRequest -from adcp.types.domains.brand.verify_brand_claim_response import ( - VerifyBrandClaimErrorResponse, - VerifyBrandClaimPayload, - VerifyBrandClaimResponse, - VerifyBrandClaimSignedResponse, - VerifyBrandClaimSignedSuccessPayload, - VerifyBrandClaimSuccessResponse, -) -from adcp.types.domains.brand.verify_brand_claims_request import ( - Claim, - Claim1, - Claim2, - Claim3, - ClaimEntry, - ClaimEntry1, - ClaimEntry2, - ClaimEntry3, - ClaimEntry4, - Property, - Store, - VerifyBrandClaimsRequest, - VerifyBrandClaimsRequestBulk, -) -from adcp.types.domains.brand.verify_brand_claims_response import ( - ResultEntry, - ResultEntry1, - ResultEntry2, - VerifyBrandClaimsErrorResponse, - VerifyBrandClaimsPayload, - VerifyBrandClaimsResponse, - VerifyBrandClaimsResponseBulk, - VerifyBrandClaimsSignedResponse, - VerifyBrandClaimsSignedSuccessPayload, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.brand.acquire_rights_request import AcquireRightsRequest, Campaign + from adcp.types.domains.brand.acquire_rights_response import ( + AcquireRightsResponse, + AcquireRightsResponse1, + AcquireRightsResponse2, + AcquireRightsResponse3, + AcquireRightsResponse4, + Disclosure, + ) + from adcp.types.domains.brand.creative_approval_request import CreativeApprovalRequest + from adcp.types.domains.brand.creative_approval_response import ( + CreativeApprovalResponse, + CreativeApprovalResponse1, + CreativeApprovalResponse2, + CreativeApprovalResponse3, + CreativeApprovalResponse4, + ) + from adcp.types.domains.brand.get_brand_identity_request import Field1, GetBrandIdentityRequest + from adcp.types.domains.brand.get_brand_identity_response import ( + Asset, + Colors, + File, + FontRole2, + Fonts, + GetBrandIdentityResponse, + GetBrandIdentityResponse1, + GetBrandIdentityResponse2, + Tone, + VoiceSynthesis, + ) + from adcp.types.domains.brand.get_rights_request import GetRightsRequest + from adcp.types.domains.brand.get_rights_response import ( + Excluded, + ExclusivityStatus, + GetRightsResponse, + GetRightsResponse1, + GetRightsResponse2, + PreviewAsset, + Right, + ) + from adcp.types.domains.brand.revocation_notification import RevocationNotification + from adcp.types.domains.brand.rights_pricing_option import RightsPricingOption + from adcp.types.domains.brand.rights_terms import Exclusivity, RightsTerms + from adcp.types.domains.brand.search_brands_request import SearchBrandsRequest + from adcp.types.domains.brand.search_brands_response import ( + Background, + ExcludedCountry, + KellerType, + Orientation, + RelationshipTrust, + SearchBrandResult, + SearchBrandsError, + SearchBrandsResponse, + SearchBrandsResponseBranches, + SearchBrandsSuccess, + Variant, + ) + from adcp.types.domains.brand.update_rights_request import UpdateRightsRequest + from adcp.types.domains.brand.update_rights_response import ( + UpdateRightsResponse, + UpdateRightsResponse1, + UpdateRightsResponse2, + ) + from adcp.types.domains.brand.verification_status import VerificationStatus + from adcp.types.domains.brand.verify_brand_claim_request import ( + VerifyBrandClaimRequest, + VerifyBrandClaimRequestBranches, + VerifyBrandClaimRequestVerifyParentClaim, + VerifyBrandClaimRequestVerifyPropertyClaim, + VerifyBrandClaimRequestVerifySubsidiaryClaim, + VerifyBrandClaimRequestVerifyTrademarkClaim, + ) + from adcp.types.domains.brand.verify_brand_claim_response import ( + VerifyBrandClaimErrorResponse, + VerifyBrandClaimPayload, + VerifyBrandClaimResponse, + VerifyBrandClaimSignedResponse, + VerifyBrandClaimSignedSuccessPayload, + VerifyBrandClaimSuccessResponse, + ) + from adcp.types.domains.brand.verify_brand_claims_request import ( + Claim, + Claim1, + Claim2, + Claim3, + ClaimEntry, + ClaimEntry1, + ClaimEntry2, + ClaimEntry3, + ClaimEntry4, + Property, + Store, + VerifyBrandClaimsRequest, + VerifyBrandClaimsRequestBulk, + ) + from adcp.types.domains.brand.verify_brand_claims_response import ( + ResultEntry, + ResultEntry1, + ResultEntry2, + VerifyBrandClaimsErrorResponse, + VerifyBrandClaimsPayload, + VerifyBrandClaimsResponse, + VerifyBrandClaimsResponseBulk, + VerifyBrandClaimsSignedResponse, + VerifyBrandClaimsSignedSuccessPayload, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "brand.acquire_rights_request": ( + "AcquireRightsRequest", + "Campaign", + ), + "brand.acquire_rights_response": ( + "AcquireRightsResponse", + "AcquireRightsResponse1", + "AcquireRightsResponse2", + "AcquireRightsResponse3", + "AcquireRightsResponse4", + "Disclosure", + ), + "brand.creative_approval_request": ("CreativeApprovalRequest",), + "brand.creative_approval_response": ( + "CreativeApprovalResponse", + "CreativeApprovalResponse1", + "CreativeApprovalResponse2", + "CreativeApprovalResponse3", + "CreativeApprovalResponse4", + ), + "brand.get_brand_identity_request": ( + "Field1", + "GetBrandIdentityRequest", + ), + "brand.get_brand_identity_response": ( + "Asset", + "Colors", + "File", + "FontRole2", + "Fonts", + "GetBrandIdentityResponse", + "GetBrandIdentityResponse1", + "GetBrandIdentityResponse2", + "Tone", + "VoiceSynthesis", + ), + "brand.get_rights_request": ("GetRightsRequest",), + "brand.get_rights_response": ( + "Excluded", + "ExclusivityStatus", + "GetRightsResponse", + "GetRightsResponse1", + "GetRightsResponse2", + "PreviewAsset", + "Right", + ), + "brand.revocation_notification": ("RevocationNotification",), + "brand.rights_pricing_option": ("RightsPricingOption",), + "brand.rights_terms": ( + "Exclusivity", + "RightsTerms", + ), + "brand.search_brands_request": ("SearchBrandsRequest",), + "brand.search_brands_response": ( + "Background", + "ExcludedCountry", + "KellerType", + "Orientation", + "RelationshipTrust", + "SearchBrandResult", + "SearchBrandsError", + "SearchBrandsResponse", + "SearchBrandsResponseBranches", + "SearchBrandsSuccess", + "Variant", + ), + "brand.update_rights_request": ("UpdateRightsRequest",), + "brand.update_rights_response": ( + "UpdateRightsResponse", + "UpdateRightsResponse1", + "UpdateRightsResponse2", + ), + "brand.verification_status": ("VerificationStatus",), + "brand.verify_brand_claim_request": ( + "VerifyBrandClaimRequest", + "VerifyBrandClaimRequestBranches", + "VerifyBrandClaimRequestVerifyParentClaim", + "VerifyBrandClaimRequestVerifyPropertyClaim", + "VerifyBrandClaimRequestVerifySubsidiaryClaim", + "VerifyBrandClaimRequestVerifyTrademarkClaim", + ), + "brand.verify_brand_claim_response": ( + "VerifyBrandClaimErrorResponse", + "VerifyBrandClaimPayload", + "VerifyBrandClaimResponse", + "VerifyBrandClaimSignedResponse", + "VerifyBrandClaimSignedSuccessPayload", + "VerifyBrandClaimSuccessResponse", + ), + "brand.verify_brand_claims_request": ( + "Claim", + "Claim1", + "Claim2", + "Claim3", + "ClaimEntry", + "ClaimEntry1", + "ClaimEntry2", + "ClaimEntry3", + "ClaimEntry4", + "Property", + "Store", + "VerifyBrandClaimsRequest", + "VerifyBrandClaimsRequestBulk", + ), + "brand.verify_brand_claims_response": ( + "ResultEntry", + "ResultEntry1", + "ResultEntry2", + "VerifyBrandClaimsErrorResponse", + "VerifyBrandClaimsPayload", + "VerifyBrandClaimsResponse", + "VerifyBrandClaimsResponseBulk", + "VerifyBrandClaimsSignedResponse", + "VerifyBrandClaimsSignedSuccessPayload", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ @@ -170,8 +338,11 @@ "RightsPricingOption", "RightsTerms", "SearchBrandResult", + "SearchBrandsError", "SearchBrandsRequest", "SearchBrandsResponse", + "SearchBrandsResponseBranches", + "SearchBrandsSuccess", "Store", "Tone", "UpdateRightsRequest", @@ -183,6 +354,11 @@ "VerifyBrandClaimErrorResponse", "VerifyBrandClaimPayload", "VerifyBrandClaimRequest", + "VerifyBrandClaimRequestBranches", + "VerifyBrandClaimRequestVerifyParentClaim", + "VerifyBrandClaimRequestVerifyPropertyClaim", + "VerifyBrandClaimRequestVerifySubsidiaryClaim", + "VerifyBrandClaimRequestVerifyTrademarkClaim", "VerifyBrandClaimResponse", "VerifyBrandClaimSignedResponse", "VerifyBrandClaimSignedSuccessPayload", diff --git a/src/adcp/types/domains/brand/search_brands_response.py b/src/adcp/types/domains/brand/search_brands_response.py index e6fa16795..69b2cccdd 100644 --- a/src/adcp/types/domains/brand/search_brands_response.py +++ b/src/adcp/types/domains/brand/search_brands_response.py @@ -1,15 +1,14 @@ # generated by datamodel-codegen: # filename: brand/search_brands_response.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum -from typing import Annotated +from typing import Annotated, Any, ClassVar, TypeAlias from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, WrapValidator, model_validator from ..core import context as context_1 from ..core import error @@ -20,9 +19,16 @@ from ..enums import advertiser_industry, right_use +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + from pydantic_core import PydanticCustomError +from adcp.types._branch_dispatch import BranchDispatchMixin + + class House(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -178,7 +184,25 @@ class SearchBrandResult(AdCPBaseModel): ] = None -class SearchBrandsResponse(AdcpVersionEnvelope, ProtocolEnvelope): +class SearchBrandsResponse(BranchDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """What every branch of brand/search-brands-response.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``SearchBrandsSuccess`` + ``SearchBrandsError`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + SearchBrandsResponseBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + brands: Annotated[ list[SearchBrandResult] | None, Field(description='Brand stubs matching the query, ranked by relevance'), @@ -192,19 +216,164 @@ class SearchBrandsResponse(AdcpVersionEnvelope, ProtocolEnvelope): context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None errors: Annotated[list[error.Error] | None, Field(min_length=1)] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'available_uses', 'background', 'brand_id', 'brands', 'buyer_reason', 'claimed_house_domain', 'code', 'context', 'countries', 'cursor', 'description', 'details', 'discriminator', 'domain', 'errors', 'excluded_countries', 'ext', 'field', 'has_more', 'house', 'industries', 'issues', 'keller_type', 'keyword', 'logos', 'message', 'name', 'names', 'orientation', 'pagination', 'pointer', 'property_name', 'recovery', 'relationship_trust', 'relationship_verified_at', 'retry_after', 'rights', 'schemaPath', 'schema_id', 'sdk_id', 'source', 'suggestion', 'total_count', 'url', 'value', 'variant'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'status'}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + SearchBrandsSuccess, + SearchBrandsError, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' @model_validator(mode='after') def _require_schema_required_group(self) -> SearchBrandsResponse: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('brands',), ('errors',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'oneOf', - 'SearchBrandsResponse requires at least one of these field groups: brands | errors', + # Compiled from brand/search-brands-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # SearchBrandsSuccess + ( + not (_c.supplied(self, 'errors')) + and (_c.descend(self, 'brands', lambda brands: _c.every(brands, lambda item: (_c.descend(item, 'brand_id', lambda brand_id: _c.json_type(brand_id, 'string')) and _c.descend(item, 'claimed_house_domain', lambda claimed_house_domain: _c.json_type(claimed_house_domain, 'string')) and _c.descend(item, 'description', lambda description: _c.json_type(description, 'string')) and _c.descend(item, 'house', lambda house: (_c.descend(house, 'domain', lambda domain: _c.json_type(domain, 'string')) and _c.descend(house, 'name', lambda name_: _c.json_type(name_, 'string'))) and (_c.supplied(house, 'domain') and _c.supplied(house, 'name')) and _c.json_type(house, 'object')) and _c.descend(item, 'industries', lambda industries: _c.every(industries, lambda item: item in ['automotive', 'automotive.electric_vehicles', 'automotive.parts_accessories', 'automotive.luxury', 'beauty_cosmetics', 'beauty_cosmetics.skincare', 'beauty_cosmetics.fragrance', 'beauty_cosmetics.haircare', 'cannabis', 'cpg', 'cpg.personal_care', 'cpg.household', 'dating', 'education', 'education.higher_education', 'education.online_learning', 'education.k12', 'energy_utilities', 'energy_utilities.renewable', 'fashion_apparel', 'fashion_apparel.luxury', 'fashion_apparel.sportswear', 'finance', 'finance.banking', 'finance.insurance', 'finance.investment', 'finance.cryptocurrency', 'food_beverage', 'food_beverage.alcohol', 'food_beverage.restaurants', 'food_beverage.packaged_goods', 'gambling_betting', 'gambling_betting.sports_betting', 'gambling_betting.casino', 'gaming', 'gaming.mobile', 'gaming.console_pc', 'gaming.esports', 'government_nonprofit', 'government_nonprofit.political', 'government_nonprofit.charity', 'healthcare', 'healthcare.pharmaceutical', 'healthcare.medical_devices', 'healthcare.wellness', 'home_garden', 'home_garden.furniture', 'home_garden.home_improvement', 'media_entertainment', 'media_entertainment.podcasts', 'media_entertainment.music', 'media_entertainment.film_tv', 'media_entertainment.publishing', 'media_entertainment.live_events', 'pets', 'professional_services', 'professional_services.legal', 'professional_services.consulting', 'real_estate', 'real_estate.residential', 'real_estate.commercial', 'recruitment_hr', 'retail', 'retail.ecommerce', 'retail.department_stores', 'sports_fitness', 'sports_fitness.equipment', 'sports_fitness.teams_leagues', 'technology', 'technology.software', 'technology.hardware', 'technology.ai_ml', 'telecom', 'telecom.mobile_carriers', 'telecom.internet_providers', 'transportation_logistics', 'travel_hospitality', 'travel_hospitality.airlines', 'travel_hospitality.hotels', 'travel_hospitality.cruise', 'travel_hospitality.tourism'] and _c.json_type(item, 'string')) and _c.length(industries, minimum=1) and _c.json_type(industries, 'array')) and _c.descend(item, 'keller_type', lambda keller_type: keller_type in ['master', 'sub_brand', 'endorsed', 'independent'] and _c.json_type(keller_type, 'string')) and _c.descend(item, 'logos', lambda logos: _c.every(logos, lambda item: (_c.descend(item, 'background', lambda background: background in ['dark-bg', 'light-bg', 'transparent-bg'] and _c.json_type(background, 'string')) and _c.descend(item, 'orientation', lambda orientation: orientation in ['square', 'horizontal', 'vertical', 'stacked'] and _c.json_type(orientation, 'string')) and _c.descend(item, 'url', lambda url: _c.json_type(url, 'string')) and _c.descend(item, 'variant', lambda variant: variant in ['primary', 'secondary', 'icon', 'wordmark', 'full-lockup'] and _c.json_type(variant, 'string'))) and _c.supplied(item, 'url') and _c.json_type(item, 'object')) and _c.json_type(logos, 'array')) and _c.descend(item, 'names', lambda names: _c.every(names, lambda item: _c.others(item, [], lambda item: _c.json_type(item, 'string')) and _c.members(item, minimum=1) and _c.json_type(item, 'object')) and _c.json_type(names, 'array')) and _c.descend(item, 'relationship_trust', lambda relationship_trust: relationship_trust in ['inline', 'mutual', 'leaf_only', 'house_only', 'standalone', 'unverifiable'] and _c.json_type(relationship_trust, 'string')) and _c.descend(item, 'relationship_verified_at', lambda relationship_verified_at: _c.json_type(relationship_verified_at, 'string')) and _c.descend(item, 'rights', lambda rights: (_c.descend(rights, 'available_uses', lambda available_uses: _c.every(available_uses, lambda item: item in ['likeness', 'voice', 'name', 'endorsement', 'motion_capture', 'signature', 'catchphrase', 'sync', 'background_music', 'editorial', 'commercial', 'ai_generated_image'] and _c.json_type(item, 'string')) and _c.json_type(available_uses, 'array')) and _c.descend(rights, 'countries', lambda countries: _c.every(countries, lambda item: _c.matches(item, '^[A-Z]{2}$') and _c.json_type(item, 'string')) and _c.json_type(countries, 'array')) and _c.descend(rights, 'excluded_countries', lambda excluded_countries: _c.every(excluded_countries, lambda item: _c.matches(item, '^[A-Z]{2}$') and _c.json_type(item, 'string')) and _c.json_type(excluded_countries, 'array'))) and _c.json_type(rights, 'object'))) and (_c.supplied(item, 'brand_id') and _c.supplied(item, 'names')) and _c.json_type(item, 'object')) and _c.json_type(brands, 'array')) and _c.descend(self, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(self, 'ext', lambda ext: _c.json_type(ext, 'object')) and _c.descend(self, 'pagination', lambda pagination: _c.only(pagination, ['cursor', 'has_more', 'total_count']) and (_c.descend(pagination, 'cursor', lambda cursor: _c.json_type(cursor, 'string')) and _c.descend(pagination, 'has_more', lambda has_more: _c.json_type(has_more, 'boolean')) and _c.descend(pagination, 'total_count', lambda total_count: _c.within(total_count, ge=0) and _c.json_type(total_count, 'integer'))) and _c.supplied(pagination, 'has_more') and _c.json_type(pagination, 'object'))) + and _c.supplied(self, 'brands') + ) + # SearchBrandsError + or ( + not (_c.supplied(self, 'brands')) + and (_c.descend(self, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(self, 'errors', lambda errors: _c.every(errors, lambda item: (not (_c.supplied(item, 'buyer_reason')) or _c.supplied(item, 'recovery')) and (_c.descend(item, 'buyer_reason', lambda buyer_reason: (_c.descend(buyer_reason, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(buyer_reason, 'message', lambda message: _c.length(message, minimum=1) and _c.json_type(message, 'string'))) and (_c.supplied(buyer_reason, 'code') and _c.supplied(buyer_reason, 'message')) and _c.json_type(buyer_reason, 'object')) and _c.descend(item, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(item, 'details', lambda details: _c.json_type(details, 'object')) and _c.descend(item, 'field', lambda field: _c.json_type(field, 'string')) and _c.descend(item, 'issues', lambda issues: _c.every(issues, lambda item: (_c.descend(item, 'discriminator', lambda discriminator: _c.every(discriminator, lambda item: _c.only(item, ['property_name', 'value']) and (_c.descend(item, 'property_name', lambda property_name: _c.json_type(property_name, 'string')) and _c.descend(item, 'value', lambda value: _c.json_type(value, 'string', 'number', 'boolean', 'null'))) and (_c.supplied(item, 'property_name') and _c.supplied(item, 'value')) and _c.json_type(item, 'object')) and _c.json_type(discriminator, 'array')) and _c.descend(item, 'keyword', lambda keyword: _c.json_type(keyword, 'string')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'pointer', lambda pointer: _c.json_type(pointer, 'string')) and _c.descend(item, 'schemaPath', lambda schemaPath: _c.json_type(schemaPath, 'string')) and _c.descend(item, 'schema_id', lambda schema_id: _c.json_type(schema_id, 'string'))) and (_c.supplied(item, 'pointer') and _c.supplied(item, 'message') and _c.supplied(item, 'keyword')) and _c.json_type(item, 'object')) and _c.json_type(issues, 'array')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'recovery', lambda recovery: recovery in ['transient', 'correctable', 'terminal'] and _c.json_type(recovery, 'string')) and _c.descend(item, 'retry_after', lambda retry_after: _c.within(retry_after, le=3600) and _c.within(retry_after, ge=1) and _c.json_type(retry_after, 'number')) and _c.descend(item, 'sdk_id', lambda sdk_id: _c.json_type(sdk_id, 'string')) and _c.descend(item, 'source', lambda source: source in ['producer', 'sdk'] and _c.json_type(source, 'string')) and _c.descend(item, 'suggestion', lambda suggestion: _c.json_type(suggestion, 'string'))) and (_c.supplied(item, 'code') and _c.supplied(item, 'message')) and _c.json_type(item, 'object')) and _c.length(errors, minimum=1) and _c.json_type(errors, 'array')) and _c.descend(self, 'ext', lambda ext: _c.json_type(ext, 'object'))) + and _c.supplied(self, 'errors') + ) + ): + raise PydanticCustomError( + 'oneOf', + 'SearchBrandsResponse requires at least one of these field groups: SearchBrandsSuccess | SearchBrandsError', + ) + if [(not (_c.supplied(self, 'errors')) and (_c.descend(self, 'brands', lambda brands: _c.every(brands, lambda item: (_c.descend(item, 'brand_id', lambda brand_id: _c.json_type(brand_id, 'string')) and _c.descend(item, 'claimed_house_domain', lambda claimed_house_domain: _c.json_type(claimed_house_domain, 'string')) and _c.descend(item, 'description', lambda description: _c.json_type(description, 'string')) and _c.descend(item, 'house', lambda house: (_c.descend(house, 'domain', lambda domain: _c.json_type(domain, 'string')) and _c.descend(house, 'name', lambda name_: _c.json_type(name_, 'string'))) and (_c.supplied(house, 'domain') and _c.supplied(house, 'name')) and _c.json_type(house, 'object')) and _c.descend(item, 'industries', lambda industries: _c.every(industries, lambda item: item in ['automotive', 'automotive.electric_vehicles', 'automotive.parts_accessories', 'automotive.luxury', 'beauty_cosmetics', 'beauty_cosmetics.skincare', 'beauty_cosmetics.fragrance', 'beauty_cosmetics.haircare', 'cannabis', 'cpg', 'cpg.personal_care', 'cpg.household', 'dating', 'education', 'education.higher_education', 'education.online_learning', 'education.k12', 'energy_utilities', 'energy_utilities.renewable', 'fashion_apparel', 'fashion_apparel.luxury', 'fashion_apparel.sportswear', 'finance', 'finance.banking', 'finance.insurance', 'finance.investment', 'finance.cryptocurrency', 'food_beverage', 'food_beverage.alcohol', 'food_beverage.restaurants', 'food_beverage.packaged_goods', 'gambling_betting', 'gambling_betting.sports_betting', 'gambling_betting.casino', 'gaming', 'gaming.mobile', 'gaming.console_pc', 'gaming.esports', 'government_nonprofit', 'government_nonprofit.political', 'government_nonprofit.charity', 'healthcare', 'healthcare.pharmaceutical', 'healthcare.medical_devices', 'healthcare.wellness', 'home_garden', 'home_garden.furniture', 'home_garden.home_improvement', 'media_entertainment', 'media_entertainment.podcasts', 'media_entertainment.music', 'media_entertainment.film_tv', 'media_entertainment.publishing', 'media_entertainment.live_events', 'pets', 'professional_services', 'professional_services.legal', 'professional_services.consulting', 'real_estate', 'real_estate.residential', 'real_estate.commercial', 'recruitment_hr', 'retail', 'retail.ecommerce', 'retail.department_stores', 'sports_fitness', 'sports_fitness.equipment', 'sports_fitness.teams_leagues', 'technology', 'technology.software', 'technology.hardware', 'technology.ai_ml', 'telecom', 'telecom.mobile_carriers', 'telecom.internet_providers', 'transportation_logistics', 'travel_hospitality', 'travel_hospitality.airlines', 'travel_hospitality.hotels', 'travel_hospitality.cruise', 'travel_hospitality.tourism'] and _c.json_type(item, 'string')) and _c.length(industries, minimum=1) and _c.json_type(industries, 'array')) and _c.descend(item, 'keller_type', lambda keller_type: keller_type in ['master', 'sub_brand', 'endorsed', 'independent'] and _c.json_type(keller_type, 'string')) and _c.descend(item, 'logos', lambda logos: _c.every(logos, lambda item: (_c.descend(item, 'background', lambda background: background in ['dark-bg', 'light-bg', 'transparent-bg'] and _c.json_type(background, 'string')) and _c.descend(item, 'orientation', lambda orientation: orientation in ['square', 'horizontal', 'vertical', 'stacked'] and _c.json_type(orientation, 'string')) and _c.descend(item, 'url', lambda url: _c.json_type(url, 'string')) and _c.descend(item, 'variant', lambda variant: variant in ['primary', 'secondary', 'icon', 'wordmark', 'full-lockup'] and _c.json_type(variant, 'string'))) and _c.supplied(item, 'url') and _c.json_type(item, 'object')) and _c.json_type(logos, 'array')) and _c.descend(item, 'names', lambda names: _c.every(names, lambda item: _c.others(item, [], lambda item: _c.json_type(item, 'string')) and _c.members(item, minimum=1) and _c.json_type(item, 'object')) and _c.json_type(names, 'array')) and _c.descend(item, 'relationship_trust', lambda relationship_trust: relationship_trust in ['inline', 'mutual', 'leaf_only', 'house_only', 'standalone', 'unverifiable'] and _c.json_type(relationship_trust, 'string')) and _c.descend(item, 'relationship_verified_at', lambda relationship_verified_at: _c.json_type(relationship_verified_at, 'string')) and _c.descend(item, 'rights', lambda rights: (_c.descend(rights, 'available_uses', lambda available_uses: _c.every(available_uses, lambda item: item in ['likeness', 'voice', 'name', 'endorsement', 'motion_capture', 'signature', 'catchphrase', 'sync', 'background_music', 'editorial', 'commercial', 'ai_generated_image'] and _c.json_type(item, 'string')) and _c.json_type(available_uses, 'array')) and _c.descend(rights, 'countries', lambda countries: _c.every(countries, lambda item: _c.matches(item, '^[A-Z]{2}$') and _c.json_type(item, 'string')) and _c.json_type(countries, 'array')) and _c.descend(rights, 'excluded_countries', lambda excluded_countries: _c.every(excluded_countries, lambda item: _c.matches(item, '^[A-Z]{2}$') and _c.json_type(item, 'string')) and _c.json_type(excluded_countries, 'array'))) and _c.json_type(rights, 'object'))) and (_c.supplied(item, 'brand_id') and _c.supplied(item, 'names')) and _c.json_type(item, 'object')) and _c.json_type(brands, 'array')) and _c.descend(self, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(self, 'ext', lambda ext: _c.json_type(ext, 'object')) and _c.descend(self, 'pagination', lambda pagination: _c.only(pagination, ['cursor', 'has_more', 'total_count']) and (_c.descend(pagination, 'cursor', lambda cursor: _c.json_type(cursor, 'string')) and _c.descend(pagination, 'has_more', lambda has_more: _c.json_type(has_more, 'boolean')) and _c.descend(pagination, 'total_count', lambda total_count: _c.within(total_count, ge=0) and _c.json_type(total_count, 'integer'))) and _c.supplied(pagination, 'has_more') and _c.json_type(pagination, 'object'))) and _c.supplied(self, 'brands')), (not (_c.supplied(self, 'brands')) and (_c.descend(self, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(self, 'errors', lambda errors: _c.every(errors, lambda item: (not (_c.supplied(item, 'buyer_reason')) or _c.supplied(item, 'recovery')) and (_c.descend(item, 'buyer_reason', lambda buyer_reason: (_c.descend(buyer_reason, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(buyer_reason, 'message', lambda message: _c.length(message, minimum=1) and _c.json_type(message, 'string'))) and (_c.supplied(buyer_reason, 'code') and _c.supplied(buyer_reason, 'message')) and _c.json_type(buyer_reason, 'object')) and _c.descend(item, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(item, 'details', lambda details: _c.json_type(details, 'object')) and _c.descend(item, 'field', lambda field: _c.json_type(field, 'string')) and _c.descend(item, 'issues', lambda issues: _c.every(issues, lambda item: (_c.descend(item, 'discriminator', lambda discriminator: _c.every(discriminator, lambda item: _c.only(item, ['property_name', 'value']) and (_c.descend(item, 'property_name', lambda property_name: _c.json_type(property_name, 'string')) and _c.descend(item, 'value', lambda value: _c.json_type(value, 'string', 'number', 'boolean', 'null'))) and (_c.supplied(item, 'property_name') and _c.supplied(item, 'value')) and _c.json_type(item, 'object')) and _c.json_type(discriminator, 'array')) and _c.descend(item, 'keyword', lambda keyword: _c.json_type(keyword, 'string')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'pointer', lambda pointer: _c.json_type(pointer, 'string')) and _c.descend(item, 'schemaPath', lambda schemaPath: _c.json_type(schemaPath, 'string')) and _c.descend(item, 'schema_id', lambda schema_id: _c.json_type(schema_id, 'string'))) and (_c.supplied(item, 'pointer') and _c.supplied(item, 'message') and _c.supplied(item, 'keyword')) and _c.json_type(item, 'object')) and _c.json_type(issues, 'array')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'recovery', lambda recovery: recovery in ['transient', 'correctable', 'terminal'] and _c.json_type(recovery, 'string')) and _c.descend(item, 'retry_after', lambda retry_after: _c.within(retry_after, le=3600) and _c.within(retry_after, ge=1) and _c.json_type(retry_after, 'number')) and _c.descend(item, 'sdk_id', lambda sdk_id: _c.json_type(sdk_id, 'string')) and _c.descend(item, 'source', lambda source: source in ['producer', 'sdk'] and _c.json_type(source, 'string')) and _c.descend(item, 'suggestion', lambda suggestion: _c.json_type(suggestion, 'string'))) and (_c.supplied(item, 'code') and _c.supplied(item, 'message')) and _c.json_type(item, 'object')) and _c.length(errors, minimum=1) and _c.json_type(errors, 'array')) and _c.descend(self, 'ext', lambda ext: _c.json_type(ext, 'object'))) and _c.supplied(self, 'errors'))].count(True) != 1: + raise PydanticCustomError( + 'oneOf', + 'SearchBrandsResponse satisfies more than one of these mutually exclusive field groups: SearchBrandsSuccess | SearchBrandsError', + ) + return self + +class SearchBrandsSuccess(SearchBrandsResponse): + """SearchBrandsSuccess. + + One branch of brand/search-brands-response.json, stated as a shape: oneOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + errors: None = None + brands: Annotated[list[SearchBrandResult], Field(description='Brand stubs matching the query, ranked by relevance')] + _ABSENT: ClassVar[frozenset[str]] = frozenset({}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({'errors'}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> SearchBrandsSuccess: + # Compiled from brand/search-brands-response.json oneOf/0: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + _c.descend(self, 'brands', lambda brands: _c.every(brands, lambda item: (_c.descend(item, 'brand_id', lambda brand_id: _c.json_type(brand_id, 'string')) and _c.descend(item, 'claimed_house_domain', lambda claimed_house_domain: _c.json_type(claimed_house_domain, 'string')) and _c.descend(item, 'description', lambda description: _c.json_type(description, 'string')) and _c.descend(item, 'house', lambda house: (_c.descend(house, 'domain', lambda domain: _c.json_type(domain, 'string')) and _c.descend(house, 'name', lambda name_: _c.json_type(name_, 'string'))) and (_c.supplied(house, 'domain') and _c.supplied(house, 'name')) and _c.json_type(house, 'object')) and _c.descend(item, 'industries', lambda industries: _c.every(industries, lambda item: item in ['automotive', 'automotive.electric_vehicles', 'automotive.parts_accessories', 'automotive.luxury', 'beauty_cosmetics', 'beauty_cosmetics.skincare', 'beauty_cosmetics.fragrance', 'beauty_cosmetics.haircare', 'cannabis', 'cpg', 'cpg.personal_care', 'cpg.household', 'dating', 'education', 'education.higher_education', 'education.online_learning', 'education.k12', 'energy_utilities', 'energy_utilities.renewable', 'fashion_apparel', 'fashion_apparel.luxury', 'fashion_apparel.sportswear', 'finance', 'finance.banking', 'finance.insurance', 'finance.investment', 'finance.cryptocurrency', 'food_beverage', 'food_beverage.alcohol', 'food_beverage.restaurants', 'food_beverage.packaged_goods', 'gambling_betting', 'gambling_betting.sports_betting', 'gambling_betting.casino', 'gaming', 'gaming.mobile', 'gaming.console_pc', 'gaming.esports', 'government_nonprofit', 'government_nonprofit.political', 'government_nonprofit.charity', 'healthcare', 'healthcare.pharmaceutical', 'healthcare.medical_devices', 'healthcare.wellness', 'home_garden', 'home_garden.furniture', 'home_garden.home_improvement', 'media_entertainment', 'media_entertainment.podcasts', 'media_entertainment.music', 'media_entertainment.film_tv', 'media_entertainment.publishing', 'media_entertainment.live_events', 'pets', 'professional_services', 'professional_services.legal', 'professional_services.consulting', 'real_estate', 'real_estate.residential', 'real_estate.commercial', 'recruitment_hr', 'retail', 'retail.ecommerce', 'retail.department_stores', 'sports_fitness', 'sports_fitness.equipment', 'sports_fitness.teams_leagues', 'technology', 'technology.software', 'technology.hardware', 'technology.ai_ml', 'telecom', 'telecom.mobile_carriers', 'telecom.internet_providers', 'transportation_logistics', 'travel_hospitality', 'travel_hospitality.airlines', 'travel_hospitality.hotels', 'travel_hospitality.cruise', 'travel_hospitality.tourism'] and _c.json_type(item, 'string')) and _c.length(industries, minimum=1) and _c.json_type(industries, 'array')) and _c.descend(item, 'keller_type', lambda keller_type: keller_type in ['master', 'sub_brand', 'endorsed', 'independent'] and _c.json_type(keller_type, 'string')) and _c.descend(item, 'logos', lambda logos: _c.every(logos, lambda item: (_c.descend(item, 'background', lambda background: background in ['dark-bg', 'light-bg', 'transparent-bg'] and _c.json_type(background, 'string')) and _c.descend(item, 'orientation', lambda orientation: orientation in ['square', 'horizontal', 'vertical', 'stacked'] and _c.json_type(orientation, 'string')) and _c.descend(item, 'url', lambda url: _c.json_type(url, 'string')) and _c.descend(item, 'variant', lambda variant: variant in ['primary', 'secondary', 'icon', 'wordmark', 'full-lockup'] and _c.json_type(variant, 'string'))) and _c.supplied(item, 'url') and _c.json_type(item, 'object')) and _c.json_type(logos, 'array')) and _c.descend(item, 'names', lambda names: _c.every(names, lambda item: _c.others(item, [], lambda item: _c.json_type(item, 'string')) and _c.members(item, minimum=1) and _c.json_type(item, 'object')) and _c.json_type(names, 'array')) and _c.descend(item, 'relationship_trust', lambda relationship_trust: relationship_trust in ['inline', 'mutual', 'leaf_only', 'house_only', 'standalone', 'unverifiable'] and _c.json_type(relationship_trust, 'string')) and _c.descend(item, 'relationship_verified_at', lambda relationship_verified_at: _c.json_type(relationship_verified_at, 'string')) and _c.descend(item, 'rights', lambda rights: (_c.descend(rights, 'available_uses', lambda available_uses: _c.every(available_uses, lambda item: item in ['likeness', 'voice', 'name', 'endorsement', 'motion_capture', 'signature', 'catchphrase', 'sync', 'background_music', 'editorial', 'commercial', 'ai_generated_image'] and _c.json_type(item, 'string')) and _c.json_type(available_uses, 'array')) and _c.descend(rights, 'countries', lambda countries: _c.every(countries, lambda item: _c.matches(item, '^[A-Z]{2}$') and _c.json_type(item, 'string')) and _c.json_type(countries, 'array')) and _c.descend(rights, 'excluded_countries', lambda excluded_countries: _c.every(excluded_countries, lambda item: _c.matches(item, '^[A-Z]{2}$') and _c.json_type(item, 'string')) and _c.json_type(excluded_countries, 'array'))) and _c.json_type(rights, 'object'))) and (_c.supplied(item, 'brand_id') and _c.supplied(item, 'names')) and _c.json_type(item, 'object')) and _c.json_type(brands, 'array')) and _c.descend(self, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(self, 'ext', lambda ext: _c.json_type(ext, 'object')) and _c.descend(self, 'pagination', lambda pagination: _c.only(pagination, ['cursor', 'has_more', 'total_count']) and (_c.descend(pagination, 'cursor', lambda cursor: _c.json_type(cursor, 'string')) and _c.descend(pagination, 'has_more', lambda has_more: _c.json_type(has_more, 'boolean')) and _c.descend(pagination, 'total_count', lambda total_count: _c.within(total_count, ge=0) and _c.json_type(total_count, 'integer'))) and _c.supplied(pagination, 'has_more') and _c.json_type(pagination, 'object')) + ): + raise PydanticCustomError( + 'oneOf', + 'SearchBrandsSuccess violates what brand/search-brands-response.json states for its oneOf/0 branch beyond the fields it requires and forbids', + ) + return self + + +class SearchBrandsError(SearchBrandsResponse): + """SearchBrandsError. + + One branch of brand/search-brands-response.json, stated as a shape: oneOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + brands: None = None + errors: Annotated[list[error.Error], Field(min_length=1)] + _ABSENT: ClassVar[frozenset[str]] = frozenset({}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({'brands'}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) + ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> SearchBrandsError: + # Compiled from brand/search-brands-response.json oneOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + _c.descend(self, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(self, 'errors', lambda errors: _c.every(errors, lambda item: (not (_c.supplied(item, 'buyer_reason')) or _c.supplied(item, 'recovery')) and (_c.descend(item, 'buyer_reason', lambda buyer_reason: (_c.descend(buyer_reason, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(buyer_reason, 'message', lambda message: _c.length(message, minimum=1) and _c.json_type(message, 'string'))) and (_c.supplied(buyer_reason, 'code') and _c.supplied(buyer_reason, 'message')) and _c.json_type(buyer_reason, 'object')) and _c.descend(item, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(item, 'details', lambda details: _c.json_type(details, 'object')) and _c.descend(item, 'field', lambda field: _c.json_type(field, 'string')) and _c.descend(item, 'issues', lambda issues: _c.every(issues, lambda item: (_c.descend(item, 'discriminator', lambda discriminator: _c.every(discriminator, lambda item: _c.only(item, ['property_name', 'value']) and (_c.descend(item, 'property_name', lambda property_name: _c.json_type(property_name, 'string')) and _c.descend(item, 'value', lambda value: _c.json_type(value, 'string', 'number', 'boolean', 'null'))) and (_c.supplied(item, 'property_name') and _c.supplied(item, 'value')) and _c.json_type(item, 'object')) and _c.json_type(discriminator, 'array')) and _c.descend(item, 'keyword', lambda keyword: _c.json_type(keyword, 'string')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'pointer', lambda pointer: _c.json_type(pointer, 'string')) and _c.descend(item, 'schemaPath', lambda schemaPath: _c.json_type(schemaPath, 'string')) and _c.descend(item, 'schema_id', lambda schema_id: _c.json_type(schema_id, 'string'))) and (_c.supplied(item, 'pointer') and _c.supplied(item, 'message') and _c.supplied(item, 'keyword')) and _c.json_type(item, 'object')) and _c.json_type(issues, 'array')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'recovery', lambda recovery: recovery in ['transient', 'correctable', 'terminal'] and _c.json_type(recovery, 'string')) and _c.descend(item, 'retry_after', lambda retry_after: _c.within(retry_after, le=3600) and _c.within(retry_after, ge=1) and _c.json_type(retry_after, 'number')) and _c.descend(item, 'sdk_id', lambda sdk_id: _c.json_type(sdk_id, 'string')) and _c.descend(item, 'source', lambda source: source in ['producer', 'sdk'] and _c.json_type(source, 'string')) and _c.descend(item, 'suggestion', lambda suggestion: _c.json_type(suggestion, 'string'))) and (_c.supplied(item, 'code') and _c.supplied(item, 'message')) and _c.json_type(item, 'object')) and _c.length(errors, minimum=1) and _c.json_type(errors, 'array')) and _c.descend(self, 'ext', lambda ext: _c.json_type(ext, 'object')) + ): + raise PydanticCustomError( + 'oneOf', + 'SearchBrandsError violates what brand/search-brands-response.json states for its oneOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +#: brand/search-brands-response.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate SearchBrandsResponse to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +SearchBrandsResponseBranches: TypeAlias = Annotated[ + SearchBrandsSuccess | SearchBrandsError, + WrapValidator( + _c.name_the_combinator('oneOf', SearchBrandsResponse._branch_models(), SearchBrandsResponse._ROOT_REQUIRED) + ), +] + +# SearchBrandsResponse is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +SearchBrandsResponse.model_rebuild(force=True) diff --git a/src/adcp/types/domains/brand/verify_brand_claim_request.py b/src/adcp/types/domains/brand/verify_brand_claim_request.py index 26bb42925..88dbb7f23 100644 --- a/src/adcp/types/domains/brand/verify_brand_claim_request.py +++ b/src/adcp/types/domains/brand/verify_brand_claim_request.py @@ -1,23 +1,29 @@ # generated by datamodel-codegen: # filename: brand/verify_brand_claim_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest from adcp.types._str_enum import StrEnum -from typing import Any +from typing import Annotated, Any, ClassVar, Literal, TypeAlias from ..core.version_envelope import AdcpVersionEnvelope -from pydantic import model_validator +from pydantic import WrapValidator, model_validator + + +from adcp.types import _conditions as _c from pydantic_core import PydanticCustomError +from adcp.types._branch_dispatch import BranchDispatchMixin + + class ClaimType(StrEnum): subsidiary = 'subsidiary' parent = 'parent' @@ -25,22 +31,221 @@ class ClaimType(StrEnum): trademark = 'trademark' -class VerifyBrandClaimRequest(AdcpRequest, AdcpVersionEnvelope): - claim_type: ClaimType - claim: dict[str, Any] +class VerifyBrandClaimRequest(BranchDispatchMixin, AdcpRequest, AdcpVersionEnvelope): + """What every branch of brand/verify-brand-claim-request.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``VerifyBrandClaimRequestVerifySubsidiaryClaim`` + ``VerifyBrandClaimRequestVerifyParentClaim`` + ``VerifyBrandClaimRequestVerifyPropertyClaim`` + ``VerifyBrandClaimRequestVerifyTrademarkClaim`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + VerifyBrandClaimRequestBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + + claim_type: ClaimType | None = None + claim: dict[str, Any] | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'claim', 'claim_type', 'claimant_says', 'countries', 'identifier', 'mark', 'number', 'observed_at', 'parent_domain', 'property', 'region', 'registry', 'store', 'subsidiary_brand_id', 'subsidiary_domain', 'type', 'use_case'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + VerifyBrandClaimRequestVerifySubsidiaryClaim, + VerifyBrandClaimRequestVerifyParentClaim, + VerifyBrandClaimRequestVerifyPropertyClaim, + VerifyBrandClaimRequestVerifyTrademarkClaim, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' @model_validator(mode='after') def _require_schema_required_group(self) -> VerifyBrandClaimRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('claim_type', 'claim'),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'oneOf', - 'VerifyBrandClaimRequest requires at least one of these field groups: claim_type+claim', - ) + # Compiled from brand/verify-brand-claim-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # VerifySubsidiaryClaim + ( + (_c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'observed_at', lambda observed_at: _c.json_type(observed_at, 'string')) and _c.descend(claim, 'subsidiary_brand_id', lambda subsidiary_brand_id: _c.matches(subsidiary_brand_id, '^[a-z0-9_]+$') and _c.json_type(subsidiary_brand_id, 'string')) and _c.descend(claim, 'subsidiary_domain', lambda subsidiary_domain: _c.json_type(subsidiary_domain, 'string'))) and _c.supplied(claim, 'subsidiary_domain') and _c.json_type(claim, 'object')) and _c.descend(self, 'claim_type', lambda claim_type: claim_type == 'subsidiary' and _c.json_type(claim_type, 'string'))) + and (_c.supplied(self, 'claim_type') and _c.supplied(self, 'claim')) + ) + # VerifyParentClaim + or ( + (_c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'claimant_says', lambda claimant_says: _c.json_type(claimant_says, 'string')) and _c.descend(claim, 'observed_at', lambda observed_at: _c.json_type(observed_at, 'string')) and _c.descend(claim, 'parent_domain', lambda parent_domain: _c.json_type(parent_domain, 'string'))) and _c.supplied(claim, 'parent_domain') and _c.json_type(claim, 'object')) and _c.descend(self, 'claim_type', lambda claim_type: claim_type == 'parent' and _c.json_type(claim_type, 'string'))) + and (_c.supplied(self, 'claim_type') and _c.supplied(self, 'claim')) + ) + # VerifyPropertyClaim + or ( + (_c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'property', lambda property: (_c.descend(property, 'identifier', lambda identifier: _c.json_type(identifier, 'string')) and _c.descend(property, 'region', lambda region: _c.json_type(region, 'string')) and _c.descend(property, 'store', lambda store: store in ['apple', 'google', 'amazon', 'roku', 'fire_tv', 'samsung', 'lg', 'vizio', 'other'] and _c.json_type(store, 'string')) and _c.descend(property, 'type', lambda type: type in ['website', 'mobile_app', 'ctv_app', 'desktop_app', 'dooh', 'podcast', 'radio', 'linear_tv', 'streaming_audio', 'ai_assistant'] and _c.json_type(type, 'string'))) and (_c.supplied(property, 'type') and _c.supplied(property, 'identifier')) and _c.json_type(property, 'object')) and _c.descend(claim, 'use_case', lambda use_case: _c.length(use_case, maximum=100) and _c.json_type(use_case, 'string'))) and _c.supplied(claim, 'property') and _c.json_type(claim, 'object')) and _c.descend(self, 'claim_type', lambda claim_type: claim_type == 'property' and _c.json_type(claim_type, 'string'))) + and (_c.supplied(self, 'claim_type') and _c.supplied(self, 'claim')) + ) + # VerifyTrademarkClaim + or ( + (_c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'countries', lambda countries: _c.every(countries, lambda item: _c.length(item, maximum=2) and _c.length(item, minimum=2) and _c.json_type(item, 'string')) and _c.json_type(countries, 'array')) and _c.descend(claim, 'mark', lambda mark: _c.length(mark, maximum=200) and _c.length(mark, minimum=1) and _c.json_type(mark, 'string')) and _c.descend(claim, 'number', lambda number: _c.length(number, maximum=100) and _c.json_type(number, 'string')) and _c.descend(claim, 'registry', lambda registry: _c.length(registry, maximum=50) and _c.json_type(registry, 'string')) and _c.descend(claim, 'use_case', lambda use_case: _c.length(use_case, maximum=100) and _c.json_type(use_case, 'string'))) and _c.supplied(claim, 'mark') and _c.json_type(claim, 'object')) and _c.descend(self, 'claim_type', lambda claim_type: claim_type == 'trademark' and _c.json_type(claim_type, 'string'))) + and (_c.supplied(self, 'claim_type') and _c.supplied(self, 'claim')) + ) + ): + raise PydanticCustomError( + 'oneOf', + 'VerifyBrandClaimRequest requires at least one of these field groups: VerifySubsidiaryClaim | VerifyParentClaim | VerifyPropertyClaim | VerifyTrademarkClaim', + ) + if [((_c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'observed_at', lambda observed_at: _c.json_type(observed_at, 'string')) and _c.descend(claim, 'subsidiary_brand_id', lambda subsidiary_brand_id: _c.matches(subsidiary_brand_id, '^[a-z0-9_]+$') and _c.json_type(subsidiary_brand_id, 'string')) and _c.descend(claim, 'subsidiary_domain', lambda subsidiary_domain: _c.json_type(subsidiary_domain, 'string'))) and _c.supplied(claim, 'subsidiary_domain') and _c.json_type(claim, 'object')) and _c.descend(self, 'claim_type', lambda claim_type: claim_type == 'subsidiary' and _c.json_type(claim_type, 'string'))) and (_c.supplied(self, 'claim_type') and _c.supplied(self, 'claim'))), ((_c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'claimant_says', lambda claimant_says: _c.json_type(claimant_says, 'string')) and _c.descend(claim, 'observed_at', lambda observed_at: _c.json_type(observed_at, 'string')) and _c.descend(claim, 'parent_domain', lambda parent_domain: _c.json_type(parent_domain, 'string'))) and _c.supplied(claim, 'parent_domain') and _c.json_type(claim, 'object')) and _c.descend(self, 'claim_type', lambda claim_type: claim_type == 'parent' and _c.json_type(claim_type, 'string'))) and (_c.supplied(self, 'claim_type') and _c.supplied(self, 'claim'))), ((_c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'property', lambda property: (_c.descend(property, 'identifier', lambda identifier: _c.json_type(identifier, 'string')) and _c.descend(property, 'region', lambda region: _c.json_type(region, 'string')) and _c.descend(property, 'store', lambda store: store in ['apple', 'google', 'amazon', 'roku', 'fire_tv', 'samsung', 'lg', 'vizio', 'other'] and _c.json_type(store, 'string')) and _c.descend(property, 'type', lambda type: type in ['website', 'mobile_app', 'ctv_app', 'desktop_app', 'dooh', 'podcast', 'radio', 'linear_tv', 'streaming_audio', 'ai_assistant'] and _c.json_type(type, 'string'))) and (_c.supplied(property, 'type') and _c.supplied(property, 'identifier')) and _c.json_type(property, 'object')) and _c.descend(claim, 'use_case', lambda use_case: _c.length(use_case, maximum=100) and _c.json_type(use_case, 'string'))) and _c.supplied(claim, 'property') and _c.json_type(claim, 'object')) and _c.descend(self, 'claim_type', lambda claim_type: claim_type == 'property' and _c.json_type(claim_type, 'string'))) and (_c.supplied(self, 'claim_type') and _c.supplied(self, 'claim'))), ((_c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'countries', lambda countries: _c.every(countries, lambda item: _c.length(item, maximum=2) and _c.length(item, minimum=2) and _c.json_type(item, 'string')) and _c.json_type(countries, 'array')) and _c.descend(claim, 'mark', lambda mark: _c.length(mark, maximum=200) and _c.length(mark, minimum=1) and _c.json_type(mark, 'string')) and _c.descend(claim, 'number', lambda number: _c.length(number, maximum=100) and _c.json_type(number, 'string')) and _c.descend(claim, 'registry', lambda registry: _c.length(registry, maximum=50) and _c.json_type(registry, 'string')) and _c.descend(claim, 'use_case', lambda use_case: _c.length(use_case, maximum=100) and _c.json_type(use_case, 'string'))) and _c.supplied(claim, 'mark') and _c.json_type(claim, 'object')) and _c.descend(self, 'claim_type', lambda claim_type: claim_type == 'trademark' and _c.json_type(claim_type, 'string'))) and (_c.supplied(self, 'claim_type') and _c.supplied(self, 'claim')))].count(True) != 1: + raise PydanticCustomError( + 'oneOf', + 'VerifyBrandClaimRequest satisfies more than one of these mutually exclusive field groups: VerifySubsidiaryClaim | VerifyParentClaim | VerifyPropertyClaim | VerifyTrademarkClaim', + ) + return self + +class VerifyBrandClaimRequestVerifySubsidiaryClaim(VerifyBrandClaimRequest): + """VerifySubsidiaryClaim. + + One branch of brand/verify-brand-claim-request.json, stated as a shape: oneOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + claim: dict[str, Any] + claim_type: Literal['subsidiary'] + @model_validator(mode='after') + def _require_schema_required_group(self) -> VerifyBrandClaimRequestVerifySubsidiaryClaim: + # Compiled from brand/verify-brand-claim-request.json oneOf/0: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + _c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'observed_at', lambda observed_at: _c.json_type(observed_at, 'string')) and _c.descend(claim, 'subsidiary_brand_id', lambda subsidiary_brand_id: _c.matches(subsidiary_brand_id, '^[a-z0-9_]+$') and _c.json_type(subsidiary_brand_id, 'string')) and _c.descend(claim, 'subsidiary_domain', lambda subsidiary_domain: _c.json_type(subsidiary_domain, 'string'))) and _c.supplied(claim, 'subsidiary_domain') and _c.json_type(claim, 'object')) + ): + raise PydanticCustomError( + 'oneOf', + 'VerifyBrandClaimRequestVerifySubsidiaryClaim violates what brand/verify-brand-claim-request.json states for its oneOf/0 branch beyond the fields it requires and forbids', + ) + return self + + +class VerifyBrandClaimRequestVerifyParentClaim(VerifyBrandClaimRequest): + """VerifyParentClaim. + + One branch of brand/verify-brand-claim-request.json, stated as a shape: oneOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + claim: dict[str, Any] + claim_type: Literal['parent'] + @model_validator(mode='after') + def _require_schema_required_group(self) -> VerifyBrandClaimRequestVerifyParentClaim: + # Compiled from brand/verify-brand-claim-request.json oneOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + _c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'claimant_says', lambda claimant_says: _c.json_type(claimant_says, 'string')) and _c.descend(claim, 'observed_at', lambda observed_at: _c.json_type(observed_at, 'string')) and _c.descend(claim, 'parent_domain', lambda parent_domain: _c.json_type(parent_domain, 'string'))) and _c.supplied(claim, 'parent_domain') and _c.json_type(claim, 'object')) + ): + raise PydanticCustomError( + 'oneOf', + 'VerifyBrandClaimRequestVerifyParentClaim violates what brand/verify-brand-claim-request.json states for its oneOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +class VerifyBrandClaimRequestVerifyPropertyClaim(VerifyBrandClaimRequest): + """VerifyPropertyClaim. + + One branch of brand/verify-brand-claim-request.json, stated as a shape: oneOf/2 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + claim: dict[str, Any] + claim_type: Literal['property'] + @model_validator(mode='after') + def _require_schema_required_group(self) -> VerifyBrandClaimRequestVerifyPropertyClaim: + # Compiled from brand/verify-brand-claim-request.json oneOf/2: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + _c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'property', lambda property: (_c.descend(property, 'identifier', lambda identifier: _c.json_type(identifier, 'string')) and _c.descend(property, 'region', lambda region: _c.json_type(region, 'string')) and _c.descend(property, 'store', lambda store: store in ['apple', 'google', 'amazon', 'roku', 'fire_tv', 'samsung', 'lg', 'vizio', 'other'] and _c.json_type(store, 'string')) and _c.descend(property, 'type', lambda type: type in ['website', 'mobile_app', 'ctv_app', 'desktop_app', 'dooh', 'podcast', 'radio', 'linear_tv', 'streaming_audio', 'ai_assistant'] and _c.json_type(type, 'string'))) and (_c.supplied(property, 'type') and _c.supplied(property, 'identifier')) and _c.json_type(property, 'object')) and _c.descend(claim, 'use_case', lambda use_case: _c.length(use_case, maximum=100) and _c.json_type(use_case, 'string'))) and _c.supplied(claim, 'property') and _c.json_type(claim, 'object')) + ): + raise PydanticCustomError( + 'oneOf', + 'VerifyBrandClaimRequestVerifyPropertyClaim violates what brand/verify-brand-claim-request.json states for its oneOf/2 branch beyond the fields it requires and forbids', + ) + return self + + +class VerifyBrandClaimRequestVerifyTrademarkClaim(VerifyBrandClaimRequest): + """VerifyTrademarkClaim. + + One branch of brand/verify-brand-claim-request.json, stated as a shape: oneOf/3 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + claim: dict[str, Any] + claim_type: Literal['trademark'] + @model_validator(mode='after') + def _require_schema_required_group(self) -> VerifyBrandClaimRequestVerifyTrademarkClaim: + # Compiled from brand/verify-brand-claim-request.json oneOf/3: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + _c.descend(self, 'claim', lambda claim: (_c.descend(claim, 'countries', lambda countries: _c.every(countries, lambda item: _c.length(item, maximum=2) and _c.length(item, minimum=2) and _c.json_type(item, 'string')) and _c.json_type(countries, 'array')) and _c.descend(claim, 'mark', lambda mark: _c.length(mark, maximum=200) and _c.length(mark, minimum=1) and _c.json_type(mark, 'string')) and _c.descend(claim, 'number', lambda number: _c.length(number, maximum=100) and _c.json_type(number, 'string')) and _c.descend(claim, 'registry', lambda registry: _c.length(registry, maximum=50) and _c.json_type(registry, 'string')) and _c.descend(claim, 'use_case', lambda use_case: _c.length(use_case, maximum=100) and _c.json_type(use_case, 'string'))) and _c.supplied(claim, 'mark') and _c.json_type(claim, 'object')) + ): + raise PydanticCustomError( + 'oneOf', + 'VerifyBrandClaimRequestVerifyTrademarkClaim violates what brand/verify-brand-claim-request.json states for its oneOf/3 branch beyond the fields it requires and forbids', + ) + return self + + +#: brand/verify-brand-claim-request.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate VerifyBrandClaimRequest to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +VerifyBrandClaimRequestBranches: TypeAlias = Annotated[ + VerifyBrandClaimRequestVerifySubsidiaryClaim | VerifyBrandClaimRequestVerifyParentClaim | VerifyBrandClaimRequestVerifyPropertyClaim | VerifyBrandClaimRequestVerifyTrademarkClaim, + WrapValidator( + _c.name_the_combinator('oneOf', VerifyBrandClaimRequest._branch_models(), VerifyBrandClaimRequest._ROOT_REQUIRED) + ), +] + +# VerifyBrandClaimRequest is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +VerifyBrandClaimRequest.model_rebuild(force=True) diff --git a/src/adcp/types/domains/brand/verify_brand_claims_response.py b/src/adcp/types/domains/brand/verify_brand_claims_response.py index 31e8b68e2..b0b006d17 100644 --- a/src/adcp/types/domains/brand/verify_brand_claims_response.py +++ b/src/adcp/types/domains/brand/verify_brand_claims_response.py @@ -63,7 +63,7 @@ class ResultEntry(RootModel[ResultEntry1 | ResultEntry2]): root: Annotated[ ResultEntry1 | ResultEntry2, Field( - description='One entry in `results[]`. Either a per-claim success (claim_type + status + optional details/context_note) or a per-claim error (error field only). Mirrors the single-target `verify_brand_claim` response success arm shape.' + description='One entry in `results[]`. Either a per-claim success (claim_type + status + optional details/context_note) or a per-claim error (error field only). Mirrors the single-target `verify_brand_claim` response success branch shape.' ), ] diff --git a/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py b/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py index 2518ba7e0..d5fdb7226 100644 --- a/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py +++ b/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: bundled/protocol/get_adcp_capabilities_response.json -# timestamp: 2026-10-04T01:57:23+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -12,7 +12,6 @@ from typing import Annotated, Any, Dict, Literal from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, SchemaInt -from adcp.types._scalar import ScalarFloat, ScalarInt, ScalarStr from pydantic import ( AnyUrl, AwareDatetime, @@ -23,9 +22,26 @@ StrictBool, StrictFloat, StringConstraints, + model_validator, ) +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarFloat, ScalarInt, ScalarStr + + +from pydantic_core import PydanticCustomError + + +from ...protocol.get_adcp_capabilities_response import AcceptancePolicyDiscovery as _CanonicalAcceptancePolicyDiscovery + + +from ...core.canonical_product import PublisherDomain as _CanonicalPublisherDomain + + +from ...protocol.get_adcp_capabilities_response import PrimaryCountry as _CanonicalPrimaryCountry + + class Status(StrEnum): submitted = 'submitted' working = 'working' @@ -6408,8 +6424,12 @@ class ContentStandards(AdCPBaseModel): class Portfolio(AdCPBaseModel): + # Same inlined-twice view as acceptance_policy_discovery above. This one is a scalar root + # rather than an object, which is why the runtime patch this replaced had to match the clone + # by class identity and not by type[BaseModel] -- a bundled scalar root is a str subclass + # (#1277). publisher_domains: Annotated[ - list[PublisherDomain], + list[_CanonicalPublisherDomain], Field( description="Publisher domains this seller is authorized to represent. Buyers should fetch each publisher's adagents.json for property definitions.", min_length=1, @@ -6421,8 +6441,11 @@ class Portfolio(AdCPBaseModel): description="Complete list of AdCP media channels for which this sales agent accepts and can meaningfully answer product-discovery briefs. When present, this is an exhaustive brief-routing allowlist: buyers MAY skip the agent when a brief's requested channels do not intersect it. Omission means channel scope is unknown and MUST NOT be interpreted as support for every channel. This is a routing pre-filter, not a promise of current product availability." ), ] = None + # Same inlined-twice view, and the two renderings do not even share a base: the bundled clone + # subclasses the inlined Country enum while the canonical class is a ScalarStr. Accepting the + # canonical one is what makes a composed portfolio typed rather than dict-shaped. primary_countries: Annotated[ - list[PrimaryCountry] | None, + list[_CanonicalPrimaryCountry] | None, Field( description="Complete list of ISO 3166-1 alpha-2 countries for which this sales agent accepts and can meaningfully answer product-discovery briefs. When present, this is an exhaustive brief-routing allowlist: buyers MAY skip the agent when a brief's requested countries do not intersect it. Omission means country scope is unknown and MUST NOT be interpreted as global coverage. This is a routing pre-filter, not a promise of current product availability and not an executable geo-targeting declaration; media_buy.execution.targeting geo capabilities and each product's overlay_support remain authoritative for targeting execution." ), @@ -6891,6 +6914,12 @@ class Features1(AdCPBaseModel): description='Structured support for canonical bidding by authored scope, allocation context, mode, strength, and strength-qualified multi-field combination. Presence does not imply support for both scopes, both allocation modes, or every policy shape. Sellers MUST preserve every advertised semantic exactly and reject unadvertised policies rather than translating them.' ), ] = None + canonical_creatives: Annotated[ + StrictBool | None, + Field( + description='Advertises canonical creative identity on AdCP 3.1. AdCP 3.2+ is canonical by contract. Published after the bundled capabilities schema; negotiation must not silently discard this evidence.' + ), + ] = None class AggregateFrequencyCapping(FrequencyCapConstraints): @@ -6913,8 +6942,12 @@ class MediaBuy(AdCPBaseModel): description='Whether this seller accepts product discovery without caller credentials. This applies to list_products and to get_products in brief or wholesale mode; it does not apply to proposal refinement or finalization, purchasing, account-scoped reads, or mutations. true means an anonymous discovery request can produce a successful response, but the response may be a public subset and may differ from results for an authenticated principal or selected account. false means these discovery calls require an authenticated principal. Absence means unspecified legacy behavior, so callers probe and handle AUTH_MISSING. A valid authenticated request is never rejected merely because credentials were supplied. true is inconsistent with account.required_for_products=true because an anonymous caller cannot select protected account context.' ), ] = None + # The bundled view inlines core/acceptance-policy-discovery.json, which the canonical view + # reaches by $ref, so the same wire schema is generated twice as two classes. This capability + # boundary accepts the canonical class, so a typed object composes without a model_dump() + # round trip. acceptance_policy_discovery: Annotated[ - AcceptancePolicyDiscovery | None, + _CanonicalAcceptancePolicyDiscovery | None, Field( description='Registry-backed seller acceptance-policy discovery. Presence means the seller publishes a versioned catalog; it does not claim that the seller evaluates acceptance_context during discovery. Discovery is advisory, exact task responses remain authoritative, and absent capability means support is unknown rather than unrestricted acceptance.' ), @@ -7335,3 +7368,150 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): description='Per-agent wholesale product-feed and wholesale signals-feed webhook capabilities. Consumers register durable sync_accounts notification subscribers and receive actual product.*, signal.*, or wholesale_feed.bulk_change payloads without polling. Product mirrors bootstrap and repair through list_products(if_feed_version); signal mirrors use get_signals(if_wholesale_feed_version). Deprecated wholesale get_products remains the 3.x product compatibility path. Webhook emission MUST apply the same caller/account authorization and cache-scope predicate as the corresponding read.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetAdcpCapabilitiesResponse: + # Compiled from bundled/protocol/get-adcp-capabilities-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'task_status') or _c.supplied(self, 'response_status')) + and _c.sent(self, 'status') + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 0) over response_status, status, task_status', + {'fields': 'response_status, status, task_status'}, + ) + if not ( + (((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'measurement.gateway')) and _c.descend(self, 'supported_protocols', lambda supported_protocols: _c.some(supported_protocols, lambda item: item == 'measurement'))) and _c.sent(self, 'experimental_features')) if _c.sent(self, 'measurement_gateway') else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 1) over experimental_features, measurement_gateway, supported_protocols', + {'fields': 'experimental_features, measurement_gateway, supported_protocols'}, + ) + if not ( + (((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'measurement.core')) and _c.descend(self, 'supported_protocols', lambda supported_protocols: _c.some(supported_protocols, lambda item: item == 'measurement'))) and _c.sent(self, 'experimental_features')) if _c.sent(self, 'measurement') else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 2) over experimental_features, measurement, supported_protocols', + {'fields': 'experimental_features, measurement, supported_protocols'}, + ) + if not ( + ((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'measurement.core')) and _c.sent(self, 'experimental_features')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.sent(media_buy, 'performance_feedback')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 3) over experimental_features, media_buy, performance_feedback', + {'fields': 'experimental_features, media_buy, performance_feedback'}, + ) + if not ( + ((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'protocol.principal')) and _c.sent(self, 'experimental_features')) if (_c.descend(self, 'adcp', lambda adcp: _c.sent(adcp, 'principal')) and _c.sent(self, 'adcp')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 4) over adcp, experimental_features, principal', + {'fields': 'adcp, experimental_features, principal'}, + ) + if not ( + (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'capability_changes', lambda capability_changes: _c.descend(capability_changes, 'notifications', lambda notifications: (_c.descend(notifications, 'registration_task', lambda registration_task: registration_task == 'sync_principal') and _c.descend(notifications, 'supported', lambda supported: supported == True)) and (_c.sent(notifications, 'supported') and _c.sent(notifications, 'registration_task'))) and _c.sent(capability_changes, 'notifications')) and _c.sent(adcp, 'capability_changes')) if (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'principal', lambda principal: _c.descend(principal, 'supported_sections', lambda supported_sections: _c.some(supported_sections, lambda item: item == 'notification_configs')) and _c.sent(principal, 'supported_sections')) and _c.sent(adcp, 'principal')) and _c.sent(self, 'adcp')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 5) over adcp, capability_changes, notifications, principal, registration_task, supported, supported_sections', + {'fields': 'adcp, capability_changes, notifications, principal, registration_task, supported, supported_sections'}, + ) + if not ( + (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'principal', lambda principal: _c.descend(principal, 'supported_sections', lambda supported_sections: _c.some(supported_sections, lambda item: item == 'notification_configs')) and _c.sent(principal, 'supported_sections')) and _c.sent(adcp, 'principal')) if (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'capability_changes', lambda capability_changes: _c.descend(capability_changes, 'notifications', lambda notifications: _c.descend(notifications, 'registration_task', lambda registration_task: registration_task == 'sync_principal') and _c.sent(notifications, 'registration_task')) and _c.sent(capability_changes, 'notifications')) and _c.sent(adcp, 'capability_changes')) and _c.sent(self, 'adcp')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 6) over adcp, capability_changes, notifications, principal, registration_task, supported_sections', + {'fields': 'adcp, capability_changes, notifications, principal, registration_task, supported_sections'}, + ) + if not ( + ((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'media_buy.reporting_delivery')) and _c.sent(self, 'experimental_features')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.sent(media_buy, 'reporting_delivery')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 7) over experimental_features, media_buy, reporting_delivery', + {'fields': 'experimental_features, media_buy, reporting_delivery'}, + ) + if not ( + ((_c.descend(self, 'webhook_signing', lambda webhook_signing: _c.descend(webhook_signing, 'supported', lambda supported: supported == True) and (_c.sent(webhook_signing, 'supported') and _c.sent(webhook_signing, 'profile') and _c.sent(webhook_signing, 'algorithms') and _c.sent(webhook_signing, 'legacy_hmac_fallback'))) and _c.sent(self, 'webhook_signing')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.descend(media_buy, 'reporting_delivery', lambda reporting_delivery: _c.sent(reporting_delivery, 'readiness_notification') or _c.sent(reporting_delivery, 'status_notification') or _c.sent(reporting_delivery, 'ledger_notification')) and _c.sent(media_buy, 'reporting_delivery')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 8) over algorithms, ledger_notification, legacy_hmac_fallback, media_buy, profile, readiness_notification, reporting_delivery, status_notification, supported, webhook_signing', + {'fields': 'algorithms, ledger_notification, legacy_hmac_fallback, media_buy, profile, readiness_notification, reporting_delivery, status_notification, supported, webhook_signing'}, + ) + if not ( + ((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'media_buy.audience_activation')) and _c.sent(self, 'experimental_features')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.descend(media_buy, 'audience_targeting', lambda audience_targeting: _c.sent(audience_targeting, 'supported_activation_methods')) and _c.sent(media_buy, 'audience_targeting')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 9) over audience_targeting, experimental_features, media_buy, supported_activation_methods', + {'fields': 'audience_targeting, experimental_features, media_buy, supported_activation_methods'}, + ) + if not ( + (_c.descend(self, 'request_signing', lambda request_signing: _c.descend(request_signing, 'covers_content_digest', lambda covers_content_digest: covers_content_digest == 'required') and _c.sent(request_signing, 'covers_content_digest')) if ((_c.descend(self, 'request_signing', lambda request_signing: _c.descend(request_signing, 'supported', lambda supported: supported == True)) and _c.sent(self, 'request_signing')) and ((_c.descend(self, 'adcp_version', lambda adcp_version: _c.matches(adcp_version, '^(?:3\\.(?:[2-9]|[1-9][0-9]+)|(?:[4-9]|[1-9][0-9]+)\\.\\d+)(?:-[a-zA-Z0-9.-]+)?$')) and _c.sent(self, 'adcp_version')) or (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'supported_versions', lambda supported_versions: _c.some(supported_versions, lambda item: _c.matches(item, '^(?:3\\.(?:[2-9]|[1-9][0-9]+)|(?:[4-9]|[1-9][0-9]+)\\.\\d+)(?:-[a-zA-Z0-9.-]+)?$') and _c.json_type(item, 'string'))) and _c.sent(adcp, 'supported_versions')) and _c.sent(self, 'adcp')))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 10) over adcp, adcp_version, covers_content_digest, request_signing, supported, supported_versions', + {'fields': 'adcp, adcp_version, covers_content_digest, request_signing, supported, supported_versions'}, + ) + if not ( + (_c.descend(self, 'adcp', lambda adcp: _c.sent(adcp, 'attestations')) if (_c.descend(self, 'governance', lambda governance: _c.sent(governance, 'runtime_attestations')) and _c.sent(self, 'governance')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 11) over adcp, attestations, governance, runtime_attestations', + {'fields': 'adcp, attestations, governance, runtime_attestations'}, + ) + if not ( + (_c.descend(self, 'adcp', lambda adcp: _c.sent(adcp, 'attestations')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.descend(media_buy, 'audience_evidence', lambda audience_evidence: _c.descend(audience_evidence, 'supports_attestation_evaluation', lambda supports_attestation_evaluation: supports_attestation_evaluation == True) and _c.sent(audience_evidence, 'supports_attestation_evaluation')) and _c.sent(media_buy, 'audience_evidence')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 12) over adcp, attestations, audience_evidence, media_buy, supports_attestation_evaluation', + {'fields': 'adcp, attestations, audience_evidence, media_buy, supports_attestation_evaluation'}, + ) + if not ( + (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'attestations', lambda attestations: _c.descend(attestations, 'accepted_claim_types', lambda accepted_claim_types: _c.some(accepted_claim_types, lambda item: item == 'https://adcontextprotocol.org/claims/rights/grant'))) and _c.sent(adcp, 'attestations')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.sent(media_buy, 'rights_attestations')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 13) over accepted_claim_types, adcp, attestations, media_buy, rights_attestations', + {'fields': 'accepted_claim_types, adcp, attestations, media_buy, rights_attestations'}, + ) + if not ( + ((_c.descend(self, 'webhook_signing', lambda webhook_signing: _c.descend(webhook_signing, 'supported', lambda supported: supported == True) and (_c.sent(webhook_signing, 'supported') and _c.sent(webhook_signing, 'profile') and _c.sent(webhook_signing, 'algorithms') and _c.sent(webhook_signing, 'legacy_hmac_fallback'))) and _c.sent(self, 'webhook_signing')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.sent(media_buy, 'relationship_notifications')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 14) over algorithms, legacy_hmac_fallback, media_buy, profile, relationship_notifications, supported, webhook_signing', + {'fields': 'algorithms, legacy_hmac_fallback, media_buy, profile, relationship_notifications, supported, webhook_signing'}, + ) + if not ( + ((_c.descend(self, 'supported_protocols', lambda supported_protocols: _c.some(supported_protocols, lambda item: item == 'creative')) and _c.sent(self, 'supported_protocols')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.descend(media_buy, 'relationship_notifications', lambda relationship_notifications: _c.descend(relationship_notifications, 'projection_tasks', lambda projection_tasks: _c.some(projection_tasks, lambda item: item == 'list_creatives')) and _c.sent(relationship_notifications, 'projection_tasks')) and _c.sent(media_buy, 'relationship_notifications')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 15) over media_buy, projection_tasks, relationship_notifications, supported_protocols', + {'fields': 'media_buy, projection_tasks, relationship_notifications, supported_protocols'}, + ) + if not ( + (not (True) if ((_c.descend(self, 'account', lambda account: _c.descend(account, 'required_for_products', lambda required_for_products: required_for_products == True) and _c.sent(account, 'required_for_products')) and _c.descend(self, 'media_buy', lambda media_buy: _c.descend(media_buy, 'anonymous_discovery', lambda anonymous_discovery: anonymous_discovery == True) and _c.sent(media_buy, 'anonymous_discovery'))) and (_c.sent(self, 'media_buy') and _c.sent(self, 'account'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (bundled/protocol/get-adcp-capabilities-response.json, root conjunct 16) over account, anonymous_discovery, media_buy, required_for_products', + {'fields': 'account, anonymous_discovery, media_buy, required_for_products'}, + ) + return self diff --git a/src/adcp/types/domains/collection/__init__.py b/src/adcp/types/domains/collection/__init__.py index cde3db016..f4f36dd79 100644 --- a/src/adcp/types/domains/collection/__init__.py +++ b/src/adcp/types/domains/collection/__init__.py @@ -9,53 +9,140 @@ it from its own schema's module, ``adcp.types.domains.collection.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.collection.base_collection_source import ( - BaseCollectionSource, - BaseCollectionSource1, - BaseCollectionSource2, - BaseCollectionSource3, - Identifier, -) -from adcp.types.domains.collection.collection_list import CollectionList -from adcp.types.domains.collection.collection_list_changed_webhook import ( - ChangeSummary, - CollectionListChangedWebhook, -) -from adcp.types.domains.collection.collection_list_filters import ( - CollectionListFilters, - ExcludeDistributionId, -) -from adcp.types.domains.collection.create_collection_list_request import CreateCollectionListRequest -from adcp.types.domains.collection.create_collection_list_response import ( - CreateCollectionListResponse, -) -from adcp.types.domains.collection.delete_collection_list_request import DeleteCollectionListRequest -from adcp.types.domains.collection.delete_collection_list_response import ( - DeleteCollectionListResponse, -) -from adcp.types.domains.collection.get_collection_list_request import ( - GetCollectionListRequest, - Pagination, -) -from adcp.types.domains.collection.get_collection_list_response import ( - Collection, - CoverageGap, - DistributionId, - GetCollectionListResponse, -) -from adcp.types.domains.collection.list_collection_lists_request import ListCollectionListsRequest -from adcp.types.domains.collection.list_collection_lists_response import ListCollectionListsResponse -from adcp.types.domains.collection.update_collection_list_request import UpdateCollectionListRequest -from adcp.types.domains.collection.update_collection_list_response import ( - UpdateCollectionListResponse, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.collection.base_collection_source import ( + BaseCollectionSource, + BaseCollectionSource1, + BaseCollectionSource2, + BaseCollectionSource3, + Identifier, + ) + from adcp.types.domains.collection.collection_list import CollectionList + from adcp.types.domains.collection.collection_list_changed_webhook import ( + ChangeSummary, + CollectionListChangedWebhook, + ) + from adcp.types.domains.collection.collection_list_filters import ( + CollectionListFilters, + ExcludeDistributionId, + ) + from adcp.types.domains.collection.create_collection_list_request import ( + CreateCollectionListRequest, + ) + from adcp.types.domains.collection.create_collection_list_response import ( + CreateCollectionListResponse, + ) + from adcp.types.domains.collection.delete_collection_list_request import ( + DeleteCollectionListRequest, + ) + from adcp.types.domains.collection.delete_collection_list_response import ( + DeleteCollectionListResponse, + ) + from adcp.types.domains.collection.get_collection_list_request import ( + GetCollectionListRequest, + Pagination, + ) + from adcp.types.domains.collection.get_collection_list_response import ( + Collection, + CoverageGap, + DistributionId, + GetCollectionListResponse, + ) + from adcp.types.domains.collection.list_collection_lists_request import ( + ListCollectionListsRequest, + ) + from adcp.types.domains.collection.list_collection_lists_response import ( + ListCollectionListsResponse, + ) + from adcp.types.domains.collection.update_collection_list_request import ( + UpdateCollectionListRequest, + ) + from adcp.types.domains.collection.update_collection_list_response import ( + UpdateCollectionListResponse, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "collection.base_collection_source": ( + "BaseCollectionSource", + "BaseCollectionSource1", + "BaseCollectionSource2", + "BaseCollectionSource3", + "Identifier", + ), + "collection.collection_list": ("CollectionList",), + "collection.collection_list_changed_webhook": ( + "ChangeSummary", + "CollectionListChangedWebhook", + ), + "collection.collection_list_filters": ( + "CollectionListFilters", + "ExcludeDistributionId", + ), + "collection.create_collection_list_request": ("CreateCollectionListRequest",), + "collection.create_collection_list_response": ("CreateCollectionListResponse",), + "collection.delete_collection_list_request": ("DeleteCollectionListRequest",), + "collection.delete_collection_list_response": ("DeleteCollectionListResponse",), + "collection.get_collection_list_request": ( + "GetCollectionListRequest", + "Pagination", + ), + "collection.get_collection_list_response": ( + "Collection", + "CoverageGap", + "DistributionId", + "GetCollectionListResponse", + ), + "collection.list_collection_lists_request": ("ListCollectionListsRequest",), + "collection.list_collection_lists_response": ("ListCollectionListsResponse",), + "collection.update_collection_list_request": ("UpdateCollectionListRequest",), + "collection.update_collection_list_response": ("UpdateCollectionListResponse",), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/compliance/__init__.py b/src/adcp/types/domains/compliance/__init__.py index 63727ff15..e118121f6 100644 --- a/src/adcp/types/domains/compliance/__init__.py +++ b/src/adcp/types/domains/compliance/__init__.py @@ -9,54 +9,136 @@ it from its own schema's module, ``adcp.types.domains.compliance.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.compliance.comply_test_controller_request import ( - Account, - AdvanceTo, - Arm, - ComplyTestControllerRequest, - Kind, - Metric, - NotYetMeasurableVendorMetric, - NotYetMeasurableVendorMetricsByPackageItem, - Operation, - Params, - PurgeKind, - ReachWindow, - ReportedSpend, - TargetHealth, -) -from adcp.types.domains.compliance.comply_test_controller_response import ( - AttestationMode, - ComplyResponseArm, - ComplyTestControllerResponse, - ComplyTestControllerResponse1, - ComplyTestControllerResponse2, - ComplyTestControllerResponse3, - ComplyTestControllerResponse4, - ComplyTestControllerResponse5, - ComplyTestControllerResponse6, - ComplyTestControllerResponse7, - ComplyTestControllerResponse8, - Error, - Forced, - IdentifierMatchProof, - Method, - Purpose, - RecordedCalls, - RecordedCalls1, - RecordedCalls2, -) -from adcp.types.domains.compliance.get_creative_features_completion import ( - GetCreativeFeaturesComplianceCompletion, -) -from adcp.types.domains.compliance.task_completion_data import ComplianceTaskCompletionData +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.compliance.comply_test_controller_request import ( + Account, + AdvanceTo, + Arm, + ComplyTestControllerRequest, + Kind, + Metric, + NotYetMeasurableVendorMetric, + NotYetMeasurableVendorMetricsByPackageItem, + Operation, + Params, + PurgeKind, + ReachWindow, + ReportedSpend, + TargetHealth, + ) + from adcp.types.domains.compliance.comply_test_controller_response import ( + AttestationMode, + ComplyResponseArm, + ComplyTestControllerResponse, + ComplyTestControllerResponse1, + ComplyTestControllerResponse2, + ComplyTestControllerResponse3, + ComplyTestControllerResponse4, + ComplyTestControllerResponse5, + ComplyTestControllerResponse6, + ComplyTestControllerResponse7, + ComplyTestControllerResponse8, + Error, + Forced, + IdentifierMatchProof, + Method, + Purpose, + RecordedCalls, + RecordedCalls1, + RecordedCalls2, + ) + from adcp.types.domains.compliance.get_creative_features_completion import ( + GetCreativeFeaturesComplianceCompletion, + ) + from adcp.types.domains.compliance.task_completion_data import ComplianceTaskCompletionData + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "compliance.comply_test_controller_request": ( + "Account", + "AdvanceTo", + "Arm", + "ComplyTestControllerRequest", + "Kind", + "Metric", + "NotYetMeasurableVendorMetric", + "NotYetMeasurableVendorMetricsByPackageItem", + "Operation", + "Params", + "PurgeKind", + "ReachWindow", + "ReportedSpend", + "TargetHealth", + ), + "compliance.comply_test_controller_response": ( + "AttestationMode", + "ComplyResponseArm", + "ComplyTestControllerResponse", + "ComplyTestControllerResponse1", + "ComplyTestControllerResponse2", + "ComplyTestControllerResponse3", + "ComplyTestControllerResponse4", + "ComplyTestControllerResponse5", + "ComplyTestControllerResponse6", + "ComplyTestControllerResponse7", + "ComplyTestControllerResponse8", + "Error", + "Forced", + "IdentifierMatchProof", + "Method", + "Purpose", + "RecordedCalls", + "RecordedCalls1", + "RecordedCalls2", + ), + "compliance.get_creative_features_completion": ("GetCreativeFeaturesComplianceCompletion",), + "compliance.task_completion_data": ("ComplianceTaskCompletionData",), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/compliance/comply_test_controller_request.py b/src/adcp/types/domains/compliance/comply_test_controller_request.py index e5dc81d9f..43d3a232c 100644 --- a/src/adcp/types/domains/compliance/comply_test_controller_request.py +++ b/src/adcp/types/domains/compliance/comply_test_controller_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: compliance/comply_test_controller_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -11,8 +11,7 @@ from typing import Annotated, Any, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..core import brand_ref, committed_metric from ..core import context as context_1 @@ -25,6 +24,13 @@ from . import task_completion_data +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Operation(StrEnum): seed_inaccessible_item = 'seed_inaccessible_item' query_eligibility = 'query_eligibility' @@ -516,3 +522,253 @@ class ComplyTestControllerRequest(AdcpRequest, AdcpVersionEnvelope): description="Sandbox account assertion. The runner MUST set sandbox: true on every comply_test_controller request. The seller MUST refuse the request (returning a structured error) if the targeted account is not a sandbox account in the seller's persisted records. This field is a caller-side declaration of intent — it does not grant sandbox status; sellers verify against their own account state. The (Sandbox) verification tier is defined by this gate: real production endpoints accept sandbox-flagged traffic and process it without real-world side effects, no separate test-mode endpoint required. See spec issue #3755 and the (Sandbox) framing in #4379." ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ComplyTestControllerRequest: + # Compiled from compliance/comply-test-controller-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'account', lambda account: _c.supplied(account, 'account_id')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'expire_account_change_cursor') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 0) over account, account_id, scenario', + {'fields': 'account, account_id, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.descend(params, 'status', lambda status: status in ['processing', 'pending_review', 'approved', 'suspended', 'rejected', 'archived']) and (_c.sent(params, 'creative_id') and _c.sent(params, 'status'))) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_creative_status') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 1) over creative_id, params, scenario, status', + {'fields': 'creative_id, params, scenario, status'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'creative_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_creative_purge') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 2) over creative_id, params, scenario', + {'fields': 'creative_id, params, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.descend(params, 'status', lambda status: status in ['active', 'pending_approval', 'rejected', 'payment_required', 'suspended', 'closed']) and (_c.sent(params, 'account_id') and _c.sent(params, 'status'))) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_account_status') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 3) over account_id, params, scenario, status', + {'fields': 'account_id, params, scenario, status'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.descend(params, 'status', lambda status: status in ['pending_creatives', 'pending_start', 'active', 'paused', 'completed', 'rejected', 'canceled']) and (_c.sent(params, 'media_buy_id') and _c.sent(params, 'status'))) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_media_buy_status') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 4) over media_buy_id, params, scenario, status', + {'fields': 'media_buy_id, params, scenario, status'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'media_buy_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_media_buy_purge') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 5) over media_buy_id, params, scenario', + {'fields': 'media_buy_id, params, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: (_c.sent(params, 'task_id') if _c.descend(params, 'arm', lambda arm: arm == 'submitted') else True) and _c.descend(params, 'arm', lambda arm: arm in ['submitted', 'input-required']) and _c.sent(params, 'arm')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_create_media_buy_arm') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 6) over arm, params, scenario, task_id', + {'fields': 'arm, params, scenario, task_id'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: (((not (_c.sent(params, 'reason') or _c.sent(params, 'suggestions')) and _c.sent(params, 'task_id')) if _c.descend(params, 'arm', lambda arm: arm == 'submitted') else True) and ((not (_c.sent(params, 'task_id') or _c.sent(params, 'message')) and _c.sent(params, 'reason')) if _c.descend(params, 'arm', lambda arm: arm == 'rejected') else True)) and _c.descend(params, 'arm', lambda arm: arm in ['submitted', 'rejected']) and _c.sent(params, 'arm')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_get_products_arm') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 7) over arm, message, params, reason, scenario, suggestions, task_id', + {'fields': 'arm, message, params, reason, scenario, suggestions, task_id'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: (_c.sent(params, 'task_id') if _c.descend(params, 'arm', lambda arm: arm == 'submitted') else True) and _c.descend(params, 'arm', lambda arm: arm == 'submitted') and _c.sent(params, 'arm')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_get_signals_arm') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 8) over arm, params, scenario, task_id', + {'fields': 'arm, params, scenario, task_id'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: [_c.descend(params, 'arm', lambda arm: arm == 'submitted') and (_c.sent(params, 'arm') and _c.sent(params, 'task_id') and _c.sent(params, 'evaluation_id')), (_c.descend(params, 'arm', lambda arm: arm == 'completed') and _c.descend(params, 'result', lambda result: (_c.sent(result, 'evaluation_id') and _c.sent(result, 'results')) and _c.json_type(result, 'object'))) and (_c.sent(params, 'arm') and _c.sent(params, 'result'))].count(True) == 1) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_get_creative_features_arm') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 9) over arm, evaluation_id, params, result, results, scenario, task_id', + {'fields': 'arm, evaluation_id, params, result, results, scenario, task_id'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'task_id') and _c.sent(params, 'result')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_task_completion') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 10) over params, result, scenario, task_id', + {'fields': 'params, result, scenario, task_id'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.descend(params, 'status', lambda status: status in ['complete', 'terminated']) and (_c.sent(params, 'session_id') and _c.sent(params, 'status'))) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_session_status') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 11) over params, scenario, session_id, status', + {'fields': 'params, scenario, session_id, status'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'media_buy_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'simulate_delivery') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 12) over media_buy_id, params, scenario', + {'fields': 'media_buy_id, params, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: (_c.sent(params, 'account_id') or _c.sent(params, 'media_buy_id')) and _c.sent(params, 'spend_percentage')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'simulate_budget_spend') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 13) over account_id, media_buy_id, params, scenario, spend_percentage', + {'fields': 'account_id, media_buy_id, params, scenario, spend_percentage'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'product_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'seed_product') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 14) over params, product_id, scenario', + {'fields': 'params, product_id, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'product_id') and _c.sent(params, 'pricing_option_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'seed_pricing_option') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 15) over params, pricing_option_id, product_id, scenario', + {'fields': 'params, pricing_option_id, product_id, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'creative_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'seed_creative') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 16) over creative_id, params, scenario', + {'fields': 'creative_id, params, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'plan_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'seed_plan') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 17) over params, plan_id, scenario', + {'fields': 'params, plan_id, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'media_buy_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'seed_media_buy') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 18) over media_buy_id, params, scenario', + {'fields': 'media_buy_id, params, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'account_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'seed_account') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 19) over account_id, params, scenario', + {'fields': 'account_id, params, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'rights_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'seed_rights_grant') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 20) over params, rights_id, scenario', + {'fields': 'params, rights_id, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'format_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'seed_creative_format') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 21) over format_id, params, scenario', + {'fields': 'format_id, params, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'vendor') and _c.sent(params, 'metrics')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'seed_measurement_catalog') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 22) over metrics, params, scenario, vendor', + {'fields': 'metrics, params, scenario, vendor'}, + ) + if not ( + (_c.descend(self, 'params', lambda params: _c.descend(params, 'attestation_mode', lambda attestation_mode: attestation_mode in ['raw', 'digest'] and _c.json_type(attestation_mode, 'string')) and _c.descend(params, 'endpoint_pattern', lambda endpoint_pattern: _c.json_type(endpoint_pattern, 'string')) and _c.descend(params, 'identifier_value_digests', lambda identifier_value_digests: _c.every(identifier_value_digests, lambda item: _c.matches(item, '^[a-f0-9]{64}$') and _c.json_type(item, 'string')) and _c.length(identifier_value_digests, maximum=64) and _c.json_type(identifier_value_digests, 'array')) and _c.descend(params, 'limit', lambda limit: _c.within(limit, le=1000) and _c.within(limit, ge=1) and _c.json_type(limit, 'integer')) and _c.descend(params, 'since_timestamp', lambda since_timestamp: _c.json_type(since_timestamp, 'string'))) if _c.descend(self, 'scenario', lambda scenario: scenario == 'query_upstream_traffic') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 23) over attestation_mode, endpoint_pattern, identifier_value_digests, limit, params, scenario, since_timestamp', + {'fields': 'attestation_mode, endpoint_pattern, identifier_value_digests, limit, params, scenario, since_timestamp'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'creative_id')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'query_provenance_audit_observations') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 24) over creative_id, params, scenario', + {'fields': 'creative_id, params, scenario'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: _c.sent(params, 'tool')) and _c.sent(self, 'params')) if _c.descend(self, 'scenario', lambda scenario: scenario == 'force_upstream_unavailable') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 25) over params, scenario, tool', + {'fields': 'params, scenario, tool'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: ((_c.sent(params, 'catalog_generation') if (_c.descend(params, 'operation', lambda operation: operation in ['query_eligibility', 'advance_time', 'recreate_catalog']) and _c.sent(params, 'operation')) else True) and (_c.sent(params, 'target_time') if (_c.descend(params, 'operation', lambda operation: operation == 'advance_time') and _c.sent(params, 'operation')) else True)) and _c.descend(params, 'operation', lambda operation: operation in ['seed_inaccessible_item', 'query_eligibility', 'advance_time', 'recreate_catalog']) and (_c.sent(params, 'operation') and _c.sent(params, 'catalog_id') and _c.sent(params, 'item_id'))) and _c.sent(self, 'params')) if (_c.descend(self, 'scenario', lambda scenario: scenario == 'catalog_item_availability_probe') and _c.sent(self, 'scenario')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 26) over catalog_generation, catalog_id, item_id, operation, params, scenario, target_time', + {'fields': 'catalog_generation, catalog_id, item_id, operation, params, scenario, target_time'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: (((not (_c.sent(params, 'proposal_id')) and _c.sent(params, 'product_id')) if (_c.descend(params, 'operation', lambda operation: operation == 'prepare') and _c.sent(params, 'operation')) else True) and ((not (_c.supplied(params, 'expires_at') or _c.sent(params, 'target_time') or _c.sent(params, 'product_id')) and _c.sent(params, 'proposal_id')) if (_c.descend(params, 'operation', lambda operation: operation == 'expire_proposal') and _c.sent(params, 'operation')) else True)) and _c.descend(params, 'operation', lambda operation: operation in ['prepare', 'expire_proposal']) and _c.sent(params, 'operation')) and _c.sent(self, 'params')) if (_c.descend(self, 'scenario', lambda scenario: scenario == 'compact_product_lifecycle_probe') and _c.sent(self, 'scenario')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 27) over expires_at, operation, params, product_id, proposal_id, scenario, target_time', + {'fields': 'expires_at, operation, params, product_id, proposal_id, scenario, target_time'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: not (_c.sent(params, 'proposal_id') or _c.supplied(params, 'expires_at') or _c.sent(params, 'target_time')) and _c.descend(params, 'operation', lambda operation: operation == 'prepare') and (_c.sent(params, 'operation') and _c.sent(params, 'product_id'))) and _c.sent(self, 'params')) if (_c.descend(self, 'scenario', lambda scenario: scenario == 'compact_direct_buy_lifecycle_probe') and _c.sent(self, 'scenario')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 28) over expires_at, operation, params, product_id, proposal_id, scenario, target_time', + {'fields': 'expires_at, operation, params, product_id, proposal_id, scenario, target_time'}, + ) + if not ( + ((_c.descend(self, 'params', lambda params: ((_c.sent(params, 'target_health') if (_c.descend(params, 'operation', lambda operation: operation == 'advance_time') and _c.sent(params, 'operation')) else not (_c.sent(params, 'target_health'))) and (_c.sent(params, 'received_reporting_revision_id') if (_c.descend(params, 'operation', lambda operation: operation == 'restate_after_received') and _c.sent(params, 'operation')) else not (_c.sent(params, 'received_reporting_revision_id') or _c.sent(params, 'advance_to')))) and _c.descend(params, 'operation', lambda operation: operation in ['prepare', 'advance_time', 'publish_zero_row', 'publish_nonempty', 'restate_snapshot', 'restate_after_received', 'omit_obligation', 'advance_past_status_deadline', 'advance_past_escalation']) and _c.sent(params, 'operation')) and _c.sent(self, 'params')) if (_c.descend(self, 'scenario', lambda scenario: scenario == 'reporting_core_lifecycle_probe') and _c.sent(self, 'scenario')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ComplyTestControllerRequest violates a constraint its schema states outside its properties (compliance/comply-test-controller-request.json, root conjunct 29) over advance_to, operation, params, received_reporting_revision_id, scenario, target_health', + {'fields': 'advance_to, operation, params, received_reporting_revision_id, scenario, target_health'}, + ) + return self diff --git a/src/adcp/types/domains/compliance/comply_test_controller_response.py b/src/adcp/types/domains/compliance/comply_test_controller_response.py index b4a45b863..dacb10c01 100644 --- a/src/adcp/types/domains/compliance/comply_test_controller_response.py +++ b/src/adcp/types/domains/compliance/comply_test_controller_response.py @@ -1,18 +1,15 @@ # generated by datamodel-codegen: # filename: compliance/comply_test_controller_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpResponse, SchemaInt -from adcp.types.response_dispatch import ResponseArmDispatchMixin - from adcp.types._str_enum import StrEnum -from typing import Annotated, Any, Literal +from typing import Annotated, Any, ClassVar, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -22,6 +19,10 @@ from ..creative import audit_observation +from adcp.types._branch_dispatch import BranchDispatchMixin +from adcp.types._scalar import ScalarStr + + class ComplyResponseArm(StrEnum): submitted = 'submitted' completed = 'completed' @@ -310,11 +311,16 @@ class Error(StrEnum): INTERNAL_ERROR = 'INTERNAL_ERROR' -class ComplyTestControllerResponse(AdcpResponse, ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): - """Constructible compatibility base for generated response arms.""" +class ComplyTestControllerResponse(AdcpResponse, BranchDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response branches.""" + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'status'}) @classmethod - def _response_arm_models(cls) -> tuple[type[ComplyTestControllerResponse], ...]: + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. return ( ComplyTestControllerResponse1, ComplyTestControllerResponse2, @@ -326,6 +332,12 @@ def _response_arm_models(cls) -> tuple[type[ComplyTestControllerResponse], ...]: ComplyTestControllerResponse8, ) + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' + class ComplyTestControllerResponse1(ComplyTestControllerResponse): model_config = ConfigDict( @@ -469,3 +481,6 @@ class ComplyTestControllerResponse8(ComplyTestControllerResponse): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + +ComplyTestControllerResponse.model_rebuild(force=True) diff --git a/src/adcp/types/domains/content_standards/__init__.py b/src/adcp/types/domains/content_standards/__init__.py index aaf3561de..0c13c47a4 100644 --- a/src/adcp/types/domains/content_standards/__init__.py +++ b/src/adcp/types/domains/content_standards/__init__.py @@ -9,93 +9,210 @@ it from its own schema's module, ``adcp.types.domains.content_standards.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.content_standards.artifact import ( - AssetAccess, - AssetAccess1, - AssetAccess2, - AssetAccess3, - Assets, - Assets1, - Assets2, - Assets3, - Assets4, - ContentFormat, - Identifiers, - Metadata, - Provider, - Role, - TranscriptFormat, - TranscriptSource, - TranscriptSource1, -) -from adcp.types.domains.content_standards.artifact_webhook_payload import ArtifactWebhookPayload -from adcp.types.domains.content_standards.calibrate_content_request import CalibrateContentRequest -from adcp.types.domains.content_standards.calibrate_content_response import ( - CalibrateContentResponse, - CalibrateContentResponse1, - CalibrateContentResponse2, -) -from adcp.types.domains.content_standards.content_standards import ContentStandards -from adcp.types.domains.content_standards.create_content_standards_request import ( - CreateContentStandardsRequest, -) -from adcp.types.domains.content_standards.create_content_standards_response import ( - CreateContentStandardsResponse, - CreateContentStandardsResponse1, - CreateContentStandardsResponse2, -) -from adcp.types.domains.content_standards.get_content_standards_request import ( - GetContentStandardsRequest, -) -from adcp.types.domains.content_standards.get_content_standards_response import ( - GetContentStandardsResponse, - GetContentStandardsResponse1, - GetContentStandardsResponse2, -) -from adcp.types.domains.content_standards.get_media_buy_artifacts_request import ( - GetMediaBuyArtifactsRequest, - TimeRange, -) -from adcp.types.domains.content_standards.get_media_buy_artifacts_response import ( - CollectionInfo, - GetMediaBuyArtifactsResponse, - GetMediaBuyArtifactsResponse1, - GetMediaBuyArtifactsResponse2, -) -from adcp.types.domains.content_standards.list_content_standards_request import ( - ListContentStandardsRequest, -) -from adcp.types.domains.content_standards.list_content_standards_response import ( - ListContentStandardsResponse, - ListContentStandardsResponse1, - ListContentStandardsResponse2, -) -from adcp.types.domains.content_standards.update_content_standards_request import ( - UpdateContentStandardsRequest, -) -from adcp.types.domains.content_standards.update_content_standards_response import ( - UpdateContentStandardsResponse, - UpdateContentStandardsResponse1, - UpdateContentStandardsResponse2, -) -from adcp.types.domains.content_standards.validate_content_delivery_request import ( - Record, - ValidateContentDeliveryRequest, -) -from adcp.types.domains.content_standards.validate_content_delivery_response import ( - Result, - Summary, - ValidateContentDeliveryResponse, - ValidateContentDeliveryResponse1, - ValidateContentDeliveryResponse2, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.content_standards.artifact import ( + AssetAccess, + AssetAccess1, + AssetAccess2, + AssetAccess3, + Assets, + Assets1, + Assets2, + Assets3, + Assets4, + ContentFormat, + Identifiers, + Metadata, + Provider, + Role, + TranscriptFormat, + TranscriptSource, + TranscriptSource1, + ) + from adcp.types.domains.content_standards.artifact_webhook_payload import ArtifactWebhookPayload + from adcp.types.domains.content_standards.calibrate_content_request import ( + CalibrateContentRequest, + ) + from adcp.types.domains.content_standards.calibrate_content_response import ( + CalibrateContentResponse, + CalibrateContentResponse1, + CalibrateContentResponse2, + ) + from adcp.types.domains.content_standards.content_standards import ContentStandards + from adcp.types.domains.content_standards.create_content_standards_request import ( + CreateContentStandardsRequest, + ) + from adcp.types.domains.content_standards.create_content_standards_response import ( + CreateContentStandardsResponse, + CreateContentStandardsResponse1, + CreateContentStandardsResponse2, + ) + from adcp.types.domains.content_standards.get_content_standards_request import ( + GetContentStandardsRequest, + ) + from adcp.types.domains.content_standards.get_content_standards_response import ( + GetContentStandardsResponse, + GetContentStandardsResponse1, + GetContentStandardsResponse2, + ) + from adcp.types.domains.content_standards.get_media_buy_artifacts_request import ( + GetMediaBuyArtifactsRequest, + TimeRange, + ) + from adcp.types.domains.content_standards.get_media_buy_artifacts_response import ( + CollectionInfo, + GetMediaBuyArtifactsResponse, + GetMediaBuyArtifactsResponse1, + GetMediaBuyArtifactsResponse2, + ) + from adcp.types.domains.content_standards.list_content_standards_request import ( + ListContentStandardsRequest, + ) + from adcp.types.domains.content_standards.list_content_standards_response import ( + ListContentStandardsResponse, + ListContentStandardsResponse1, + ListContentStandardsResponse2, + ) + from adcp.types.domains.content_standards.update_content_standards_request import ( + UpdateContentStandardsRequest, + ) + from adcp.types.domains.content_standards.update_content_standards_response import ( + UpdateContentStandardsResponse, + UpdateContentStandardsResponse1, + UpdateContentStandardsResponse2, + ) + from adcp.types.domains.content_standards.validate_content_delivery_request import ( + Record, + ValidateContentDeliveryRequest, + ) + from adcp.types.domains.content_standards.validate_content_delivery_response import ( + Result, + Summary, + ValidateContentDeliveryResponse, + ValidateContentDeliveryResponse1, + ValidateContentDeliveryResponse2, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "content_standards.artifact": ( + "AssetAccess", + "AssetAccess1", + "AssetAccess2", + "AssetAccess3", + "Assets", + "Assets1", + "Assets2", + "Assets3", + "Assets4", + "ContentFormat", + "Identifiers", + "Metadata", + "Provider", + "Role", + "TranscriptFormat", + "TranscriptSource", + "TranscriptSource1", + ), + "content_standards.artifact_webhook_payload": ("ArtifactWebhookPayload",), + "content_standards.calibrate_content_request": ("CalibrateContentRequest",), + "content_standards.calibrate_content_response": ( + "CalibrateContentResponse", + "CalibrateContentResponse1", + "CalibrateContentResponse2", + ), + "content_standards.content_standards": ("ContentStandards",), + "content_standards.create_content_standards_request": ("CreateContentStandardsRequest",), + "content_standards.create_content_standards_response": ( + "CreateContentStandardsResponse", + "CreateContentStandardsResponse1", + "CreateContentStandardsResponse2", + ), + "content_standards.get_content_standards_request": ("GetContentStandardsRequest",), + "content_standards.get_content_standards_response": ( + "GetContentStandardsResponse", + "GetContentStandardsResponse1", + "GetContentStandardsResponse2", + ), + "content_standards.get_media_buy_artifacts_request": ( + "GetMediaBuyArtifactsRequest", + "TimeRange", + ), + "content_standards.get_media_buy_artifacts_response": ( + "CollectionInfo", + "GetMediaBuyArtifactsResponse", + "GetMediaBuyArtifactsResponse1", + "GetMediaBuyArtifactsResponse2", + ), + "content_standards.list_content_standards_request": ("ListContentStandardsRequest",), + "content_standards.list_content_standards_response": ( + "ListContentStandardsResponse", + "ListContentStandardsResponse1", + "ListContentStandardsResponse2", + ), + "content_standards.update_content_standards_request": ("UpdateContentStandardsRequest",), + "content_standards.update_content_standards_response": ( + "UpdateContentStandardsResponse", + "UpdateContentStandardsResponse1", + "UpdateContentStandardsResponse2", + ), + "content_standards.validate_content_delivery_request": ( + "Record", + "ValidateContentDeliveryRequest", + ), + "content_standards.validate_content_delivery_response": ( + "Result", + "Summary", + "ValidateContentDeliveryResponse", + "ValidateContentDeliveryResponse1", + "ValidateContentDeliveryResponse2", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/content_standards/create_content_standards_request.py b/src/adcp/types/domains/content_standards/create_content_standards_request.py index e4b269173..279a1a138 100644 --- a/src/adcp/types/domains/content_standards/create_content_standards_request.py +++ b/src/adcp/types/domains/content_standards/create_content_standards_request.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: content_standards/create_content_standards_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest -from typing import Annotated, Literal +from typing import Annotated, ClassVar, Literal from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, ConfigDict, Field, model_validator @@ -19,6 +19,9 @@ from . import artifact +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -120,19 +123,29 @@ class CreateContentStandardsRequest(AdcpRequest, AdcpVersionEnvelope): ] context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'policies', 'registry_policy_ids'}) @model_validator(mode='after') def _require_schema_required_group(self) -> CreateContentStandardsRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('policies',), ('registry_policy_ids',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'CreateContentStandardsRequest requires at least one of these field groups: policies | registry_policy_ids', - ) + # Compiled from content-standards/create-content-standards-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # policies + ( + _c.sent(self, 'policies') + ) + # registry_policy_ids + or ( + _c.sent(self, 'registry_policy_ids') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'CreateContentStandardsRequest requires at least one of these field groups: policies | registry_policy_ids', + ) + return self diff --git a/src/adcp/types/domains/content_standards/create_content_standards_response.py b/src/adcp/types/domains/content_standards/create_content_standards_response.py index bab6dbf3c..27327c3fe 100644 --- a/src/adcp/types/domains/content_standards/create_content_standards_response.py +++ b/src/adcp/types/domains/content_standards/create_content_standards_response.py @@ -1,14 +1,12 @@ # generated by datamodel-codegen: # filename: content_standards/create_content_standards_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpResponse -from adcp.types.response_dispatch import ResponseArmDispatchMixin - -from typing import Annotated +from typing import Annotated, ClassVar from pydantic import Field @@ -19,16 +17,30 @@ from ..core.version_envelope import AdcpVersionEnvelope -class CreateContentStandardsResponse(AdcpResponse, ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): - """Constructible compatibility base for generated response arms.""" +from adcp.types._branch_dispatch import BranchDispatchMixin + + +class CreateContentStandardsResponse(AdcpResponse, BranchDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response branches.""" + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'status'}) @classmethod - def _response_arm_models(cls) -> tuple[type[CreateContentStandardsResponse], ...]: + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. return ( CreateContentStandardsResponse1, CreateContentStandardsResponse2, ) + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' + class CreateContentStandardsResponse1(CreateContentStandardsResponse): standards_id: Annotated[ @@ -48,3 +60,6 @@ class CreateContentStandardsResponse2(CreateContentStandardsResponse): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + +CreateContentStandardsResponse.model_rebuild(force=True) diff --git a/src/adcp/types/domains/content_standards/list_content_standards_response.py b/src/adcp/types/domains/content_standards/list_content_standards_response.py index 15d935636..98953ac95 100644 --- a/src/adcp/types/domains/content_standards/list_content_standards_response.py +++ b/src/adcp/types/domains/content_standards/list_content_standards_response.py @@ -1,14 +1,12 @@ # generated by datamodel-codegen: # filename: content_standards/list_content_standards_response.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpResponse -from adcp.types.response_dispatch import ResponseArmDispatchMixin - -from typing import Annotated +from typing import Annotated, ClassVar from pydantic import Field @@ -21,16 +19,30 @@ from . import content_standards -class ListContentStandardsResponse(AdcpResponse, ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): - """Constructible compatibility base for generated response arms.""" +from adcp.types._branch_dispatch import BranchDispatchMixin + + +class ListContentStandardsResponse(AdcpResponse, BranchDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response branches.""" + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'status'}) @classmethod - def _response_arm_models(cls) -> tuple[type[ListContentStandardsResponse], ...]: + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. return ( ListContentStandardsResponse1, ListContentStandardsResponse2, ) + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' + class ListContentStandardsResponse2(ListContentStandardsResponse): errors: list[error.Error] @@ -46,3 +58,6 @@ class ListContentStandardsResponse1(ListContentStandardsResponse): pagination: pagination_response.PaginationResponse | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + +ListContentStandardsResponse.model_rebuild(force=True) diff --git a/src/adcp/types/domains/content_standards/update_content_standards_response.py b/src/adcp/types/domains/content_standards/update_content_standards_response.py index 1b68f3c58..01c26c54a 100644 --- a/src/adcp/types/domains/content_standards/update_content_standards_response.py +++ b/src/adcp/types/domains/content_standards/update_content_standards_response.py @@ -1,14 +1,12 @@ # generated by datamodel-codegen: # filename: content_standards/update_content_standards_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpResponse -from adcp.types.response_dispatch import ResponseArmDispatchMixin - -from typing import Annotated, Literal +from typing import Annotated, ClassVar, Literal from pydantic import ConfigDict, Field @@ -19,16 +17,30 @@ from ..core.version_envelope import AdcpVersionEnvelope -class UpdateContentStandardsResponse(AdcpResponse, ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): - """Constructible compatibility base for generated response arms.""" +from adcp.types._branch_dispatch import BranchDispatchMixin + + +class UpdateContentStandardsResponse(AdcpResponse, BranchDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): + """Constructible compatibility base for generated response branches.""" + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'status'}) @classmethod - def _response_arm_models(cls) -> tuple[type[UpdateContentStandardsResponse], ...]: + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. return ( UpdateContentStandardsResponse1, UpdateContentStandardsResponse2, ) + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' + class UpdateContentStandardsResponse1(UpdateContentStandardsResponse): model_config = ConfigDict( @@ -58,3 +70,6 @@ class UpdateContentStandardsResponse2(UpdateContentStandardsResponse): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + + +UpdateContentStandardsResponse.model_rebuild(force=True) diff --git a/src/adcp/types/domains/core/__init__.py b/src/adcp/types/domains/core/__init__.py index 0680e38de..bfbf2af77 100644 --- a/src/adcp/types/domains/core/__init__.py +++ b/src/adcp/types/domains/core/__init__.py @@ -9,1746 +9,3843 @@ it from its own schema's module, ``adcp.types.domains.core.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.core.acceptance_policy_profile_ids import ( - AcceptancePolicyProfileId, - AcceptancePolicyProfileIds, -) -from adcp.types.domains.core.account import ( - Account, - Compression, - CreditLimit, - GovernanceAgent, - ReportingBucket, -) -from adcp.types.domains.core.account_authorization import ( - AccountAuthorization, - AllowedTask, - ScopeName, -) -from adcp.types.domains.core.account_change import AccountChange, Actor, Repair, UnavailableReason -from adcp.types.domains.core.account_change_recorded_webhook import AccountChangeRecordedWebhook -from adcp.types.domains.core.account_identity_change import ( - AccountIdentityChange, - AccountIdentityChange1, - AccountIdentityChange2, -) -from adcp.types.domains.core.account_identity_change_preview import ( - AccountIdentityChangePreview, - AccountIdentityChangePreview1, - AccountIdentityChangePreview2, - AccountIdentityChangePreview3, - BlockedImpacts, - Blocker, - Effect1, - Impact, - NonblockingImpact, - NonblockingImpacts, -) -from adcp.types.domains.core.account_ref import ( - AccountReference, - AccountReference1, - AccountReference2, -) -from adcp.types.domains.core.account_status_changed_webhook import AccountStatusChangedWebhook -from adcp.types.domains.core.account_timezone_capability import ( - AccountSelection, - AccountTimezoneCapability, - SupportedTimezone, -) -from adcp.types.domains.core.account_with_authorization import AccountWithAuthorization -from adcp.types.domains.core.activation_key import ActivationKey, ActivationKey1, ActivationKey2 -from adcp.types.domains.core.ad_inventory_config import AdInventoryConfiguration -from adcp.types.domains.core.agent_encryption_key import AgentEncryptionKey -from adcp.types.domains.core.agent_notification_config import AgentNotificationConfig -from adcp.types.domains.core.agent_notification_config_state import AgentNotificationConfigState -from adcp.types.domains.core.agent_reporting_destination import ( - AcceptedFormat, - AgentReportingDestination, - AgentReportingDestination1, - AgentReportingDestination2, - AgentReportingDestination3, -) -from adcp.types.domains.core.agent_reporting_destination_state import ( - AgentReportingDestinationState, - PriorDestinationRef, -) -from adcp.types.domains.core.agent_signing_key import AgentSigningKey -from adcp.types.domains.core.agent_webhook_challenge import AgentWebhookChallenge -from adcp.types.domains.core.app_item import AppItem, Platform -from adcp.types.domains.core.applicable_package_id import ApplicablePackageId -from adcp.types.domains.core.asset_group_vocabulary import AdcpAssetGroupVocabularyRegistry -from adcp.types.domains.core.assets.asset_union import ( - AssetVariant, - AudioChannelLayout, - C2paWatermarkAction, - CatalogType, - ContentIdType, - DaastAsset1, - DaastAsset2, - DaastTrackingEvent, - DaastVersion, - DigitalSourceType, - DisclosurePersistence, - DisclosurePosition, - DisplayTagAsset1, - DisplayTagAsset2, - DisplayTagAsset3, - EmbeddedProvenanceMethod, - Ext, - FeedFormat, - FieldModel, - FrameRateType, - GopType, - HttpMethod, - JavascriptModuleType, - Jurisdiction2, - Location1, - Location2, - Location4, - Location5, - Location6, - Location8, - Location9, - MacroBearingUrl1, - MacroBearingUrl2, - MacroDeclaration1, - MacroDeclaration10, - MacroDeclaration2, - MacroDeclaration3, - MacroDeclaration4, - MacroDeclaration5, - MacroDeclaration6, - MacroDeclaration7, - MacroDeclaration8, - MacroDeclaration9, - MacroDeclarationModel, - MacroDialect, - MacroMappingStatus, - MacroProcessingOperation, - MacroResolver, - MacroValueContext, - MarkdownFlavor, - MoovAtomPosition, - PixelTrackingEvent, - PlatformExtensionRef, - PublishedPostAsset1, - PublishedPostAsset2, - ReferenceAuthorization1, - Role2, - ScanType, - Target1, - UniversalMacro, - UpdateFrequency, - UrlAssetType, - VastAsset1, - VastAsset2, - VastTrackingEvent, - VastVersion, - VerifyAgent1, - WatermarkMediaType, - WebhookResponseType, - WebhookSecurityMethod, -) -from adcp.types.domains.core.assets.audio_asset import BitDepth -from adcp.types.domains.core.assets.daast_asset import ( - DaastAsset3, - DaastAsset4, - Location13, - MacroDeclaration12, -) -from adcp.types.domains.core.assets.display_tag_asset import ( - DisplayTagAsset4, - DisplayTagAsset5, - DisplayTagAsset6, - Location17, - Location18, - MacroDeclaration15, - MacroDeclaration16, -) -from adcp.types.domains.core.assets.vast_asset import ( - Location26, - MacroDeclaration20, - VastAsset3, - VastAsset4, -) -from adcp.types.domains.core.async_response_data import AdcpAsyncResponseData -from adcp.types.domains.core.async_response_refs.creative.get_creative_features_async_response_submitted import ( - GetCreativeFeaturesSubmitted, -) -from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_input_required import ( - SyncCreativesInputRequired, -) -from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_submitted import ( - SyncCreativesSubmitted, -) -from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_working import ( - SyncCreativesWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_input_required import ( - AcceptProposalInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_submitted import ( - AcceptProposalSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_working import ( - AcceptProposalWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_input_required import ( - BuildCreativeInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_submitted import ( - BuildCreativeSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_working import ( - BuildCreativeWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_input_required import ( - BuyProductsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_submitted import ( - BuyProductsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_working import ( - BuyProductsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_input_required import ( - ControlMediaBuyInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_submitted import ( - ControlMediaBuySubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_working import ( - ControlMediaBuyWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_input_required import ( - CreateMediaBuyInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_submitted import ( - CreateMediaBuySubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_working import ( - CreateMediaBuyWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_input_required import ( - DeclineProposalsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_submitted import ( - DeclineProposalsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_working import ( - DeclineProposalsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_input_required import ( - GetProductsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_submitted import ( - GetProductsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_working import ( - GetProductsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_input_required import ( - RefineProposalsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_submitted import ( - RefineProposalsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_working import ( - RefineProposalsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_input_required import ( - RequestProposalsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_submitted import ( - RequestProposalsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_working import ( - RequestProposalsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_input_required import ( - SyncCatalogsInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_submitted import ( - SyncCatalogsSubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_working import ( - SyncCatalogsWorking, -) -from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_input_required import ( - UpdateMediaBuyInputRequired, -) -from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_submitted import ( - UpdateMediaBuySubmitted, -) -from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_working import ( - UpdateMediaBuyWorking, -) -from adcp.types.domains.core.async_response_refs.signals.get_signals_async_response_submitted import ( - GetSignalsSubmitted, -) -from adcp.types.domains.core.async_response_refs.signals.get_signals_async_response_working import ( - GetSignalsWorking, -) -from adcp.types.domains.core.attestation_capabilities import ( - AcceptedIssuer, - AttestationCapabilities, - CredentialOrigin, - Resolver, - SupportedDeliveryMethod, -) -from adcp.types.domains.core.attestation_evaluation import Outcome -from adcp.types.domains.core.attestation_issuer import ( - AttestationIssuer, - AttestationIssuer1, - AttestationIssuer2, - AttestationIssuer3, -) -from adcp.types.domains.core.attestation_reference import ( - AttestationReference, - EmbeddedCredential, - Locator, - Locator1, - ValidityHint, -) -from adcp.types.domains.core.attestation_subject import ( - AttestationSubject, - AttestationSubject1, - AttestationSubject2, - AttestationSubject3, -) -from adcp.types.domains.core.attribution_window import AttributionWindow -from adcp.types.domains.core.audience_activation_method import ( - AudienceActivationMethod, - AudienceActivationMethod1, - AudienceActivationMethod2, - AudienceActivationMethod3, - AudienceActivationMethod4, - AudienceActivationMethod5, - AudienceActivationMethod6, - Cloud, - ConsumerIdentity, -) -from adcp.types.domains.core.audience_characteristic import AudienceCharacteristic -from adcp.types.domains.core.audience_evidence import ( - AudienceEvidence, - Subject11, - Subject12, - Subject13, - Subject14, - Subject15, - Subject16, - Subject17, - Subject2, - Subject21, - Subject22, - Subject23, - Subject24, - Subject25, - Subject26, - Subject27, - Subject3, - Subject31, - Subject32, - Subject33, - Subject34, - Subject35, - Subject36, - Subject37, -) -from adcp.types.domains.core.audience_evidence_pin import AudienceEvidencePin -from adcp.types.domains.core.audience_evidence_requirements import AudienceEvidenceRequirements -from adcp.types.domains.core.audience_evidence_selection import AudienceEvidenceSelection -from adcp.types.domains.core.audience_member import AudienceMember -from adcp.types.domains.core.audience_selector import ( - AudienceSelector, - AudienceSelector1, - AudienceSelector2, - AudienceSelector3, - AudienceSelector4, -) -from adcp.types.domains.core.audience_source import AudienceSource, AudienceSource1, AudienceSource2 -from adcp.types.domains.core.authorized_agent_base import AuthorizedAgentBaseFields -from adcp.types.domains.core.bidding_policy import BiddingPolicy, CostPer, Roas, Strength, Strength1 -from adcp.types.domains.core.bidding_policy_capability import ( - BiddingPolicyCapability, - CostPerStrength, - MaxBidWithCostPer, - MaxBidWithRoas, - PolicyProfile, - RoasStrength, - ScopeCapability, -) -from adcp.types.domains.core.brand_id import BrandId -from adcp.types.domains.core.brand_key import BrandKey -from adcp.types.domains.core.brand_ref import ( - BrandKitOverride, - BrandReference, - Colors, - DataSubjectContestation, -) -from adcp.types.domains.core.brand_response_authorization_result import ( - BrandResponseAuthorizationResult, - BrandResponseAuthorizationResult1, - BrandResponseAuthorizationResult2, - Trust, -) -from adcp.types.domains.core.budget_allocation import ( - BudgetAllocation, - BudgetAllocation1, - BudgetAllocation2, - OptimizationGoal1, - OptimizationGoal2, - OptimizationGoal3, - OptimizationGoal4, - OptimizationGoal5, - OptimizationGoal6, - OptimizationGoal7, - Target3, - Target4, - Target5, - Target6, - Target7, - Target8, -) -from adcp.types.domains.core.business_entity import Bank, BusinessEntity, Contact -from adcp.types.domains.core.cancellation_policy import CancellationFee, CancellationPolicy -from adcp.types.domains.core.canonical_account_ref import ( - CanonicalAccountReference, - CanonicalAccountReference1, - CanonicalAccountReference2, -) -from adcp.types.domains.core.canonical_audience_evidence import ( - AttestationDigest, - CanonicalAudienceEvidence, -) -from adcp.types.domains.core.canonical_audience_evidence_selection import ( - CanonicalAudienceEvidenceSelection, - VerifiedAttestationDigest, -) -from adcp.types.domains.core.canonical_budget_allocation import ( - CanonicalBudgetAllocation, - CanonicalBudgetAllocation1, - CanonicalBudgetAllocation2, -) -from adcp.types.domains.core.canonical_delivery_forecast import CanonicalDeliveryForecast -from adcp.types.domains.core.canonical_forecast_point import CanonicalForecastPoint -from adcp.types.domains.core.canonical_forecast_vendor_metric_value import ( - CanonicalForecastVendorMetricValue, -) -from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind -from adcp.types.domains.core.canonical_format_option import CanonicalFormatOption, FormatKind -from adcp.types.domains.core.canonical_measurement_terms import CanonicalMeasurementTerms -from adcp.types.domains.core.canonical_media_buy_action import ( - Action3, - Action4, - CanonicalMediaBuyAction, - CanonicalMediaBuyAction1, - CanonicalMediaBuyAction2, - CanonicalMediaBuyAction3, -) -from adcp.types.domains.core.canonical_media_buy_action_fields import CanonicalMediaBuyActionFields -from adcp.types.domains.core.canonical_media_buy_features import CanonicalMediaBuyFeatures -from adcp.types.domains.core.canonical_metric_qualifier import CanonicalMetricQualifier -from adcp.types.domains.core.canonical_optimization_goal import ( - CanonicalOptimizationGoal, - CanonicalOptimizationGoal1, - CanonicalOptimizationGoal2, - CanonicalOptimizationGoal3, - Target10, - Target11, -) -from adcp.types.domains.core.canonical_performance_standard import CanonicalPerformanceStandard -from adcp.types.domains.core.canonical_placement import ( - CanonicalDoohPlacementAttributes, - CanonicalDoohScreenResolution, - CanonicalProductPlacement, - CanonicalProductPlacement1, - CanonicalProductPlacement2, -) -from adcp.types.domains.core.canonical_pricing_option import CanonicalPricingOption, PricingModel -from adcp.types.domains.core.canonical_product import ( - CanonicalProduct, - PublisherProperty1, - PublisherProperty2, - PublisherProperty3, - PublisherProperty4, - PublisherProperty5, - PublisherProperty6, - PublisherProperty7, -) -from adcp.types.domains.core.canonical_product_action import CanonicalProductAction -from adcp.types.domains.core.canonical_projection_ref import ( - AssetSource, - CanonicalProjectionReference, -) -from adcp.types.domains.core.canonical_projection_slot_override import ( - CanonicalProjectionSlotOverride, -) -from adcp.types.domains.core.canonical_proposal import CanonicalProposal, ProposalKind -from adcp.types.domains.core.canonical_reporting_capabilities import CanonicalReportingCapabilities -from adcp.types.domains.core.canonical_reporting_commitment import ( - CanonicalReportingCommitment, - CanonicalReportingCommitment1, - CanonicalReportingCommitment2, -) -from adcp.types.domains.core.canvas_constraint import CanvasConstraint, Constraint -from adcp.types.domains.core.capabilities_changed_webhook import CapabilitiesChangedWebhook -from adcp.types.domains.core.catalog_item_availability_error import CatalogItemAvailabilityError -from adcp.types.domains.core.catalog_item_availability_ref import CatalogItemAvailabilityReference -from adcp.types.domains.core.catalog_item_availability_state import CatalogItemAvailabilityState -from adcp.types.domains.core.catalog_item_availability_update import CatalogItemAvailabilityUpdate -from adcp.types.domains.core.catalog_item_availability_update_result import ( - CatalogItemAvailabilityUpdateResult, -) -from adcp.types.domains.core.catalog_item_delivery_metrics import CatalogItemDeliveryMetrics -from adcp.types.domains.core.catalog_item_reference_not_found_error import ( - CatalogItemReferenceNotFoundError, -) -from adcp.types.domains.core.catalog_selection import CatalogSelection -from adcp.types.domains.core.catchment import Catchment -from adcp.types.domains.core.collection import Collection, RelatedCollection -from adcp.types.domains.core.collection_delivery_metrics import CollectionDeliveryMetrics -from adcp.types.domains.core.collection_distribution import CollectionDistribution -from adcp.types.domains.core.collection_list_ref import CollectionListReference -from adcp.types.domains.core.collection_property_delivery_metrics import ( - CollectionPropertyDeliveryMetrics, -) -from adcp.types.domains.core.collection_ref import CollectionReference -from adcp.types.domains.core.collection_selection import ( - CollectionSelection, - CollectionSelection1, - CollectionSelection2, -) -from adcp.types.domains.core.collection_selector import CollectionSelector -from adcp.types.domains.core.committed_metric import ( - CommittedMetric, - CommittedMetric1, - CommittedMetric2, - Qualifier1, - QualifierModel, -) -from adcp.types.domains.core.compact_task_input_required import CompactTaskInputRequired -from adcp.types.domains.core.compact_task_submitted import CompactTaskSubmitted -from adcp.types.domains.core.compact_task_working import CompactTaskWorking -from adcp.types.domains.core.content_rating import ContentRating -from adcp.types.domains.core.context import ContextObject -from adcp.types.domains.core.creative_approval_scope import ScopedCreativeApproval -from adcp.types.domains.core.creative_asset import CreativeAsset, Input -from adcp.types.domains.core.creative_assets import CreativeAssets, CreativeAssets1 -from adcp.types.domains.core.creative_assignment import CreativeAssignment, RotationMode -from adcp.types.domains.core.creative_consumption import CreativeConsumption -from adcp.types.domains.core.creative_delivery_metrics import CreativeDeliveryMetrics -from adcp.types.domains.core.creative_filters import CreativeFilters -from adcp.types.domains.core.creative_item import CreativeItem, CreativeItem1, CreativeItem2 -from adcp.types.domains.core.creative_locale_policy import CreativeLocalePolicy -from adcp.types.domains.core.creative_localization import CreativeLocalization, TargetVariant -from adcp.types.domains.core.creative_localization_readback import ( - CreativeLocalizationReadback, - ResolvedAssets, - ResolvedAssets1, - Variants, - Variants1, - Variants2, -) -from adcp.types.domains.core.creative_manifest import CreativeManifest -from adcp.types.domains.core.creative_operation_format_declaration import ( - CreativeOperationFormatDeclaration, -) -from adcp.types.domains.core.creative_policy import CreativePolicy, ProvenanceRequirements -from adcp.types.domains.core.creative_representation import CreativeRepresentation -from adcp.types.domains.core.creative_representation_set import CreativeRepresentationSet -from adcp.types.domains.core.creative_revision_id import CreativeRevisionId -from adcp.types.domains.core.creative_variable import CreativeVariable, VariableType -from adcp.types.domains.core.creative_variant import Artifact, CreativeVariant, GenerationContext -from adcp.types.domains.core.daast_tracker_constraints import ( - DaastEvent, - DaastOffset, - DaastTarget, - DaastTrackerConstraints, - DaastVersions, -) -from adcp.types.domains.core.data_provider_signal_selector import ( - DataProviderSignalSelector, - DataProviderSignalSelector1, - DataProviderSignalSelector2, - DataProviderSignalSelector3, - SignalTag, -) -from adcp.types.domains.core.date_range import DateRange -from adcp.types.domains.core.datetime_range import DatetimeRange -from adcp.types.domains.core.daypart_target import DaypartTarget -from adcp.types.domains.core.deadline_policy import DeadlinePolicy, MaterialStage -from adcp.types.domains.core.delivery_breakdown_controls import DeliveryBreakdownControls -from adcp.types.domains.core.delivery_forecast import DeliveryForecast -from adcp.types.domains.core.delivery_metric_aggregate import ( - DeliveryMetricAggregate, - DeliveryMetricAggregate1, - DeliveryMetricAggregate2, - Field0, - Qualifier3, -) -from adcp.types.domains.core.delivery_metrics import ( - ByActionSourceItem, - ByEventTypeItem, - DeliveryMetrics, - DoohMetrics, - DoohMetrics1, - EstimationBasis, - OohMetrics, - Panel, - Posting, - QuartileData, - ReachWindow, - TimeBasedView, - VenueBreakdownItem, - Viewability1, - ViewedSecondsHistogramItem, - ViewedSecondsPercentiles, -) -from adcp.types.domains.core.delivery_provider import DeliveryProvider -from adcp.types.domains.core.delivery_recipient import DeliveryRecipient -from adcp.types.domains.core.demographic_age_range import DemographicAgeRange -from adcp.types.domains.core.demographic_predicate import DemographicPredicate -from adcp.types.domains.core.demographic_reporting_capability import ( - DemographicReportingCapability, - ReportingMode, -) -from adcp.types.domains.core.demographic_targeting_capability import ( - DemographicTargetingCapability, - ExecutionMode, - UnknownHandling, -) -from adcp.types.domains.core.demographic_targeting_intent import DemographicTargetingIntent -from adcp.types.domains.core.demographic_targeting_resolution import ( - DemographicTargetingResolution, - Execution, - Execution1, - Execution2, - IntervalId, -) -from adcp.types.domains.core.deployment import Deployment, Deployment1, Deployment2 -from adcp.types.domains.core.destination import Destination1, Destination2 -from adcp.types.domains.core.destination_item import DestinationItem, DestinationType -from adcp.types.domains.core.diagnostic_issue import DiagnosticIssue, Severity -from adcp.types.domains.core.downstream_connection_requirement import ( - ConnectionType, - DownstreamConnectionRequirement, - ResourceRef, -) -from adcp.types.domains.core.duration import Duration -from adcp.types.domains.core.education_item import DegreeType, EducationItem, Level, Modality -from adcp.types.domains.core.error import BuyerReason, DiscriminatorItem, Issue, Recovery -from adcp.types.domains.core.evaluator_spec import ( - EvalBudget, - EvaluatorSpec, - EvaluatorSpec1, - EvaluatorSpec2, - EvaluatorSpec3, - Exemplars, - FeatureAgent, - RankByItem, - RankByItem1, - RankByItem2, -) -from adcp.types.domains.core.event import Event -from adcp.types.domains.core.event_custom_data import Content, EventCustomData -from adcp.types.domains.core.event_source_health import Detail, EventSourceHealth -from adcp.types.domains.core.event_surface import Category, EventSurface -from adcp.types.domains.core.experimental_feature_id import ExperimentalFeatureId -from adcp.types.domains.core.ext import ExtensionObject -from adcp.types.domains.core.feature_requirement import FeatureRequirement, IfNotCovered -from adcp.types.domains.core.flight_item import FlightItem -from adcp.types.domains.core.forecast_dimension_audience import AudienceForecastDimension -from adcp.types.domains.core.forecast_dimension_device_platform import ( - DevicePlatformForecastDimension, -) -from adcp.types.domains.core.forecast_dimension_device_type import DeviceTypeForecastDimension -from adcp.types.domains.core.forecast_dimension_geo import GeoForecastDimension -from adcp.types.domains.core.forecast_dimension_placement import PlacementForecastDimension -from adcp.types.domains.core.forecast_dimension_signal import Presence, SignalForecastDimension -from adcp.types.domains.core.forecast_dimension_time import TimeForecastDimension -from adcp.types.domains.core.forecast_point import ForecastPoint, ViewableRate -from adcp.types.domains.core.forecast_point_dimensions import ForecastPointDimensions -from adcp.types.domains.core.forecast_range import ForecastRange -from adcp.types.domains.core.forecast_rate_range import ForecastRateRange -from adcp.types.domains.core.forecast_vendor_metric_value import ForecastVendorMetricValue -from adcp.types.domains.core.format import ( - Assets10, - Assets11, - Assets12, - Assets13, - Assets14, - Assets15, - Assets16, - Assets17, - Assets18, - Assets19, - Assets20, - Assets21, - Assets22, - Assets23, - Assets24, - Assets25, - Assets26, - Assets27, - Assets28, - Assets29, - Assets30, - Assets31, - Assets32, - Assets33, - Assets34, - Assets35, - Assets36, - Assets37, - Assets38, - Assets39, - Assets40, - Assets41, - Assets42, - Assets43, - Assets44, - Assets45, - Assets46, - Assets47, - Assets48, - Assets49, - Assets50, - Assets51, - Assets9, - Assets94, - BaseGroupAsset, - BaseIndividualAsset, - CanonicalParameters, - CanonicalParameters1, - CanonicalParameters10, - CanonicalParameters11, - CanonicalParameters12, - CanonicalParameters13, - CanonicalParameters14, - CanonicalParameters15, - CanonicalParameters16, - CanonicalParameters17, - CanonicalParameters18, - CanonicalParameters19, - CanonicalParameters2, - CanonicalParameters20, - CanonicalParameters21, - CanonicalParameters22, - CanonicalParameters23, - CanonicalParameters24, - CanonicalParameters25, - CanonicalParameters26, - CanonicalParameters27, - CanonicalParameters28, - CanonicalParameters29, - CanonicalParameters3, - CanonicalParameters30, - CanonicalParameters31, - CanonicalParameters32, - CanonicalParameters33, - CanonicalParameters4, - CanonicalParameters5, - CanonicalParameters6, - CanonicalParameters7, - CanonicalParameters8, - CanonicalParameters9, - Dimensions, - Dimensions1, - DisclosureCapability, - FormatCard, - FormatCardDetailed, - Renders, - Renders1, - Responsive, -) -from adcp.types.domains.core.format_id import FormatReferenceStructuredObject -from adcp.types.domains.core.format_option_ref import ( - FormatOptionReference, - FormatOptionReference1, - FormatOptionReference2, -) -from adcp.types.domains.core.format_shape_vocabulary import AdcpFormatShapeVocabularyRegistry -from adcp.types.domains.core.frequency_cap import FrequencyCap -from adcp.types.domains.core.frequency_cap_constraints import FrequencyCapConstraints -from adcp.types.domains.core.frequency_cap_duration_unit import FrequencyCapDurationUnit -from adcp.types.domains.core.frequency_cap_impression_constraints import ( - AllowedValue, - FrequencyCapImpressionConstraints, -) -from adcp.types.domains.core.frequency_cap_interval_constraints import ( - AllowedInterval, - FrequencyCapIntervalConstraints, -) -from adcp.types.domains.core.frequency_cap_requirements import FrequencyCapRequirements -from adcp.types.domains.core.generation_credential import GenerationCredential -from adcp.types.domains.core.geo_breakdown_support import GeographicBreakdownSupport -from adcp.types.domains.core.geo_delivery_metrics import GeoDeliveryMetrics -from adcp.types.domains.core.geo_metro import GeoMetro -from adcp.types.domains.core.geo_place_area import GeographicPlaceArea -from adcp.types.domains.core.geo_place_catalog_capability import ( - GeographicPlaceCatalogCapability, - SupportedVersion, -) -from adcp.types.domains.core.geo_place_catalog_entry import ( - GeographicPlaceCatalogEntry, - ParentLabel, - ReplacedByValue, -) -from adcp.types.domains.core.geo_place_requirement import ( - CatalogRequirement, - GeographicPlaceRequirement, -) -from adcp.types.domains.core.geo_place_resolver import Auth, GeographicPlaceResolver -from adcp.types.domains.core.geo_place_support import GeographicPlaceSystemSupport -from adcp.types.domains.core.geo_place_system import ( - GeographicPlaceIdentifierSystem, - GeographicPlaceIdentifierSystem1, - GeographicPlaceIdentifierSystem2, -) -from adcp.types.domains.core.geo_place_type import ( - GeographicPlaceType, - GeographicPlaceType1, - GeographicPlaceType2, -) -from adcp.types.domains.core.geo_region_requirement import Countries1, GeographicRegionRequirement -from adcp.types.domains.core.geo_region_support import Countries3, GeographicRegionSupport -from adcp.types.domains.core.get_geo_place_resolution_request import ( - GetGeographicPlaceResolutionRequest, -) -from adcp.types.domains.core.get_geo_place_resolution_response import ( - GetGeographicPlaceResolutionResponse, -) -from adcp.types.domains.core.hotel_item import HotelItem -from adcp.types.domains.core.iana_timezone import IanaTimezoneIdentifier -from adcp.types.domains.core.impairment import Impairment, Transition -from adcp.types.domains.core.indicator import Indicator -from adcp.types.domains.core.indicator_bearing import IndicatorBearingResourceState -from adcp.types.domains.core.indicator_scope import IndicatorScope -from adcp.types.domains.core.indicators_changed_webhook import ( - IndicatorsChangedWebhook, - RelationshipKind, -) -from adcp.types.domains.core.industry_identifier import IndustryIdentifier -from adcp.types.domains.core.insertion_order import InsertionOrder, PaymentTerms, Terms, TotalBudget -from adcp.types.domains.core.installment import DerivativeOf, Installment -from adcp.types.domains.core.installment_deadlines import InstallmentDeadlines -from adcp.types.domains.core.installment_delivery_metrics import InstallmentDeliveryMetrics -from adcp.types.domains.core.installment_property_delivery_metrics import ( - InstallmentPropertyDeliveryMetrics, -) -from adcp.types.domains.core.installment_ref import InstallmentReference -from adcp.types.domains.core.inventory_list_application import ( - InventoryListApplication, - InventoryListApplication1, - InventoryListApplication2, - Summary, - Summary2, -) -from adcp.types.domains.core.job_item import EmploymentType, ExperienceLevel, JobItem, Salary -from adcp.types.domains.core.keyword_delivery_metrics import KeywordDeliveryMetrics -from adcp.types.domains.core.limited_series import LimitedSeries -from adcp.types.domains.core.locale_tag import LanguageTag -from adcp.types.domains.core.localized_creative_asset import ( - LocalizedCreativeAsset, - LocalizedCreativeAsset1, - LocalizedCreativeAsset10, - LocalizedCreativeAsset11, - LocalizedCreativeAsset12, - LocalizedCreativeAsset13, - LocalizedCreativeAsset14, - LocalizedCreativeAsset15, - LocalizedCreativeAsset16, - LocalizedCreativeAsset17, - LocalizedCreativeAsset18, - LocalizedCreativeAsset19, - LocalizedCreativeAsset2, - LocalizedCreativeAsset20, - LocalizedCreativeAsset21, - LocalizedCreativeAsset22, - LocalizedCreativeAsset3, - LocalizedCreativeAsset4, - LocalizedCreativeAsset5, - LocalizedCreativeAsset7, - LocalizedCreativeAsset8, - LocalizedCreativeAsset9, -) -from adcp.types.domains.core.macro_bearing_url import MacroBearingUrl3, MacroBearingUrl4 -from adcp.types.domains.core.macro_resolution_capability import ( - MacroProcessingCapability, - MappingStatus, - Operation, -) -from adcp.types.domains.core.macro_resolution_result import MacroResolutionResult -from adcp.types.domains.core.material_deadline import MaterialDeadline -from adcp.types.domains.core.mcp_webhook_payload import McpWebhookPayload -from adcp.types.domains.core.measurement_readiness import MeasurementReadiness -from adcp.types.domains.core.measurement_terms import MeasurementTerms -from adcp.types.domains.core.measurement_window import MeasurementWindow -from adcp.types.domains.core.media_buy import MediaBuy -from adcp.types.domains.core.media_buy_available_action import MediaBuyAvailableAction -from adcp.types.domains.core.media_buy_available_action_id import MediaBuyAvailableActionId -from adcp.types.domains.core.media_buy_change_term_id import MediaBuyChangeTermId -from adcp.types.domains.core.media_buy_features import MediaBuyFeatures -from adcp.types.domains.core.media_buy_frequency_cap import MediaBuyFrequencyCap -from adcp.types.domains.core.media_buy_frequency_cap_capability import ( - MediaBuyFrequencyCapCapability, -) -from adcp.types.domains.core.media_buy_frequency_cap_requirement import ( - MediaBuyFrequencyCapRequirement, -) -from adcp.types.domains.core.media_buy_frequency_cap_support import MediaBuyFrequencyCapSupport -from adcp.types.domains.core.media_buy_legacy_terms_ref import MediaBuyTermsReference -from adcp.types.domains.core.media_buy_support import ProductMediaBuySupport -from adcp.types.domains.core.media_buy_support_requirements import ( - ProductMediaBuySupportRequirements, -) -from adcp.types.domains.core.missing_metric import MissingMetric, MissingMetric1, MissingMetric2 -from adcp.types.domains.core.negative_keyword import NegativeKeyword -from adcp.types.domains.core.notification_config import NotificationConfig -from adcp.types.domains.core.offering import GeoTargets, Offering -from adcp.types.domains.core.offering_asset_group import Items, OfferingAssetGroup -from adcp.types.domains.core.operator_identity import OperatorIdentity -from adcp.types.domains.core.operator_unit import OperatorUnit -from adcp.types.domains.core.opportunity_context import ( - CloseReason, - Intent, - OpportunityContext, - Phase, -) -from adcp.types.domains.core.optimization_goal import ( - OptimizationGoal10, - OptimizationGoal8, - OptimizationGoal9, - Target14, - Target15, - Target16, - Target17, - Target18, - Target19, -) -from adcp.types.domains.core.outcome_measurement import OutcomeMeasurement -from adcp.types.domains.core.outcome_target_cost_per import OutcomeTargetCostPer -from adcp.types.domains.core.overlay import Bounds, Overlay, Visual -from adcp.types.domains.core.package import Package -from adcp.types.domains.core.package_delivery_metric_value import PackageDeliveryMetricValue -from adcp.types.domains.core.package_format_snapshot import ( - PackageFormatSnapshot, - PackageFormatSnapshot1, - PackageFormatSnapshot10, - PackageFormatSnapshot11, - PackageFormatSnapshot12, - PackageFormatSnapshot13, - PackageFormatSnapshot14, - PackageFormatSnapshot15, - PackageFormatSnapshot16, - PackageFormatSnapshot17, - PackageFormatSnapshot18, - PackageFormatSnapshot19, - PackageFormatSnapshot2, - PackageFormatSnapshot20, - PackageFormatSnapshot21, - PackageFormatSnapshot22, - PackageFormatSnapshot23, - PackageFormatSnapshot24, - PackageFormatSnapshot25, - PackageFormatSnapshot26, - PackageFormatSnapshot27, - PackageFormatSnapshot28, - PackageFormatSnapshot29, - PackageFormatSnapshot3, - PackageFormatSnapshot30, - PackageFormatSnapshot31, - PackageFormatSnapshot32, - PackageFormatSnapshot33, - PackageFormatSnapshot4, - PackageFormatSnapshot5, - PackageFormatSnapshot6, - PackageFormatSnapshot7, - PackageFormatSnapshot8, - PackageFormatSnapshot9, -) -from adcp.types.domains.core.package_signal_targeting import ( - PackageSignalTargeting, - PackageSignalTargeting1, - PackageSignalTargeting2, - PackageSignalTargeting3, - PackageSignalTargeting4, - PackageSignalTargeting5, - PackageSignalTargeting6, - PackageSignalTargeting7, -) -from adcp.types.domains.core.package_signal_targeting_group import ( - Operator, - PackageSignalTargetingGroup, -) -from adcp.types.domains.core.package_signal_targeting_groups import PackageSignalTargetingGroups -from adcp.types.domains.core.package_targeting_resolution import PackageTargetingResolution -from adcp.types.domains.core.pagination_request import PaginationRequest -from adcp.types.domains.core.pagination_response import PaginationResponse -from adcp.types.domains.core.performance_feedback import ( - MeasurementPeriod, - Metric7, - PerformanceFeedback, -) -from adcp.types.domains.core.performance_feedback_assertion import ( - ConfidenceInterval, - PerformanceFeedbackAssertion, -) -from adcp.types.domains.core.performance_feedback_metric import ( - PerformanceFeedbackMetric, - PerformanceFeedbackMetric1, - PerformanceFeedbackMetric2, -) -from adcp.types.domains.core.performance_standard import PerformanceStandard -from adcp.types.domains.core.placement import ( - Placement, - ProductDoohPlacementAttributes, - ProductDoohScreenResolution, -) -from adcp.types.domains.core.placement_definition import ( - FormatOptions, - PlacementDefinition, - PublisherDoohPlacementAttributes, - PublisherDoohScreenResolution, -) -from adcp.types.domains.core.placement_delivery_metrics import PlacementDeliveryMetrics -from adcp.types.domains.core.placement_evidence import PlacementEvidence -from adcp.types.domains.core.placement_identity import ( - PlacementIdentity, - PlacementIdentity1, - PlacementIdentity2, -) -from adcp.types.domains.core.placement_presentation import ( - BoxDecoration, - Canvas, - Color, - CreativeSlot, - Fit, - ImageDecoration, - ImageRef, - Layer, - PlacementPresentationDocument, - Rectangle, - TextDecoration, -) -from adcp.types.domains.core.placement_property_delivery_metrics import ( - PlacementPropertyDeliveryMetrics, -) -from adcp.types.domains.core.placement_ref import PlacementReference -from adcp.types.domains.core.placement_selection import PlacementSelection1, PlacementSelection2 -from adcp.types.domains.core.planned_delivery import Geo, PlannedDelivery -from adcp.types.domains.core.platform_extension_ref import PlatformExtensionReference -from adcp.types.domains.core.positive_postal_area_support import PositivePostalAreaSupport -from adcp.types.domains.core.postal_area import ( - PostalArea, - PostalArea1, - PostalArea11, - PostalArea110, - PostalArea111, - PostalArea112, - PostalArea113, - PostalArea114, - PostalArea115, - PostalArea116, - PostalArea117, - PostalArea118, - PostalArea119, - PostalArea12, - PostalArea120, - PostalArea121, - PostalArea13, - PostalArea14, - PostalArea15, - PostalArea16, - PostalArea17, - PostalArea18, - PostalArea19, - PostalArea2, - System1, - System2, - System3, - System9, -) -from adcp.types.domains.core.postal_area_support import ( - CAEnum, - GBEnum, - ME, - PostalAreaSupport, - PostalAreaSupportAdditionalPropertyEnum, -) -from adcp.types.domains.core.postal_country_system import ( - PostalCountrySystem, - PostalCountrySystem1, - PostalCountrySystem10, - PostalCountrySystem2, - PostalCountrySystem3, - PostalCountrySystem4, - PostalCountrySystem5, - PostalCountrySystem6, - PostalCountrySystem7, - PostalCountrySystem8, - PostalCountrySystem9, - System11, - System12, - System13, - System19, -) -from adcp.types.domains.core.presentation_ref import PlacementPresentationReference -from adcp.types.domains.core.preview_provider import PublisherDesignatedPreviewProvider, Route -from adcp.types.domains.core.preview_renderer_metadata import ( - PreviewRendererMetadata, - RenderingOrigin, -) -from adcp.types.domains.core.price import Price -from adcp.types.domains.core.pricing_option import PricingOption -from adcp.types.domains.core.principal_changed_webhook import PrincipalChangedWebhook -from adcp.types.domains.core.principal_declarations import ( - AgentDeclarations, - AsyncAdcpVersion, - WebhookSigningAlgorithm, -) -from adcp.types.domains.core.principal_declarations_state import ( - Axis, - Exclusion, - PrincipalDeclarationsState, -) -from adcp.types.domains.core.principal_state import ( - DestinationRef, - PrincipalState, - RetiredDestination, -) -from adcp.types.domains.core.product import ( - AudienceActivation, - ConversionTracking, - DeliveryMeasurement, - MaterialSubmission, - MetricOptimization, - Product, - ProductCard, - ProductCardDetailed, - PublisherProperty81, - PublisherProperty82, - PublisherProperty83, - PublisherProperty84, - PublisherProperty85, - PublisherProperty86, - PublisherProperty87, - Specification, - SupportedMetric, - SupportedTarget5, - SupportedViewDuration, -) -from adcp.types.domains.core.product_allocation import ProductAllocation -from adcp.types.domains.core.product_allowed_action import ProductAllowedAction -from adcp.types.domains.core.product_audience_evidence_requirements import ( - AcceptedAttestationIssuers, - AcceptedAttestationIssuers1, - AcceptedAttestationIssuers2, - AcceptedAttestationIssuers3, - ProductAudienceEvidenceRequirements, -) -from adcp.types.domains.core.product_card_reference_asset import ProductCardReferenceAsset -from adcp.types.domains.core.product_change_map import ProductChangeMap, ProductChangeMap1 -from adcp.types.domains.core.product_execution_requirement import ( - Connection, - ProductExecutionRequirement, - ProductExecutionRequirement1, - ProductExecutionRequirement2, - ProductExecutionRequirement3, -) -from adcp.types.domains.core.product_filters import ( - AudienceActivationMethods, - AudienceActivationMethods1, - AudienceActivationMethods2, - AudienceActivationMethods3, - AudienceActivationMethods4, - AudienceActivationMethods5, - Keyword, - ProductFilters, - RequiredGeoTargetingItem, - SignalTargetingItem, - SignalTargetingItem1, - SignalTargetingItem2, - SignalTargetingItem3, - SignalTargetingItem4, - SignalTargetingItem5, - SignalTargetingItem6, - SignalTargetingItem7, -) -from adcp.types.domains.core.product_format_declaration import ( - ProductFormatDeclaration, - ProductFormatDeclaration1, - ProductFormatDeclaration10, - ProductFormatDeclaration11, - ProductFormatDeclaration12, - ProductFormatDeclaration13, - ProductFormatDeclaration14, - ProductFormatDeclaration15, - ProductFormatDeclaration16, - ProductFormatDeclaration2, - ProductFormatDeclaration3, - ProductFormatDeclaration4, - ProductFormatDeclaration5, - ProductFormatDeclaration6, - ProductFormatDeclaration7, - ProductFormatDeclaration8, - ProductFormatDeclaration9, -) -from adcp.types.domains.core.product_identity import ProductIdentity -from adcp.types.domains.core.product_offer_filters import ( - AvailabilityHorizon, - ProductOfferFilters, - RequiredPerformanceStandard, -) -from adcp.types.domains.core.product_signal_targeting_option import ( - ActivationStatus, - AllowedTargetingMode, - ProductSignalTargetingOption, -) -from adcp.types.domains.core.product_targeting_resolution import ProductTargetingResolution -from adcp.types.domains.core.property import Property -from adcp.types.domains.core.property_delivery_metrics import PropertyDeliveryMetrics -from adcp.types.domains.core.property_id import PropertyId -from adcp.types.domains.core.property_list_ref import PropertyListReference -from adcp.types.domains.core.property_ref import PropertyReference -from adcp.types.domains.core.property_tag import PropertyTag -from adcp.types.domains.core.proposal import Proposal -from adcp.types.domains.core.protocol_envelope import ProtocolEnvelope -from adcp.types.domains.core.provenance import VerifyAgent18 -from adcp.types.domains.core.publisher_property_selector import ( - PublisherPropertySelector, - PublisherPropertySelector1, - PublisherPropertySelector2, - PublisherPropertySelector3, -) -from adcp.types.domains.core.push_notification_config import PushNotificationConfig -from adcp.types.domains.core.real_estate_item import ListingType, PropertyType, RealEstateItem -from adcp.types.domains.core.reference_renderer import ReferenceRenderer -from adcp.types.domains.core.registry_event import ( - AgentProfilePayload, - AuthorizationPayload, - AuthorizationType, - BadgeRole, - ChangedFields, - Classification, - CollectionIdentifier, - CollectionPayload, - CompliancePayload, - ComplianceStatus, - DelegationType, - GradingProfile, - Market, - Payload1, - Payload10, - Payload11, - Payload12, - Payload13, - Payload14, - Payload2, - Payload3, - Payload4, - Payload5, - Payload6, - Payload7, - Payload8, - Payload9, - PropertyPayload, - PropertySource, - PublisherAdagentsPayload, - RegistryEvent, - RegistryEvent1, - RegistryEvent10, - RegistryEvent11, - RegistryEvent12, - RegistryEvent13, - RegistryEvent14, - RegistryEvent15, - RegistryEvent16, - RegistryEvent17, - RegistryEvent18, - RegistryEvent19, - RegistryEvent2, - RegistryEvent20, - RegistryEvent3, - RegistryEvent4, - RegistryEvent5, - RegistryEvent6, - RegistryEvent7, - RegistryEvent8, - RegistryEvent9, - Storyboard, - StoryboardStatus, - StringArray, - Tracks, -) -from adcp.types.domains.core.registry_feed_response import Freshness, RegistryFeedResponse -from adcp.types.domains.core.reporting_adjustment import AccountingPeriod, ReportingAdjustment -from adcp.types.domains.core.reporting_adjustment_receipt import ( - ReportingAdjustmentReceipt, - ReportingAdjustmentRejectionCode, -) -from adcp.types.domains.core.reporting_canonical_content_digest import ( - ReportingCanonicalContentDigest, -) -from adcp.types.domains.core.reporting_canonicalization_contract import ( - AdditionalItem, - EmptyReport, - GoldenVectors, - OrderingEncoding, - ReportingCanonicalizationContract, - ReportingPrimaryKey, -) -from adcp.types.domains.core.reporting_capabilities import ReportingCapabilities -from adcp.types.domains.core.reporting_consumer_status import ( - ConsumerStatus, - FailureCode, - MismatchCode, - ReportingConsumerStatus, -) -from adcp.types.domains.core.reporting_control_total import ( - ReportingControlTotal, - ReportingControlTotal1, - ReportingControlTotal2, -) -from adcp.types.domains.core.reporting_coverage import ( - Limitation, - ReportingCoverage, - ReportingMediaBuyId, - ReportingPackageId, -) -from adcp.types.domains.core.reporting_dataset_share_destination import ( - Recipient, - ReportingCloud, - ReportingDatasetShareDestination, - ReportingDatasetShareDestination1, - ReportingDatasetShareDestination2, -) -from adcp.types.domains.core.reporting_delivery_capabilities import ( - OperationsContact, - ReportingDeliveryCapabilities, -) -from adcp.types.domains.core.reporting_delivery_config import ( - AuthoritativeParty, - CoverageRequirement, - ReportingDeliveryConfiguration, -) -from adcp.types.domains.core.reporting_delivery_config_state import ( - ReportingDeliveryConfigLifecycleState, - ReportingDeliveryConfigurationState, -) -from adcp.types.domains.core.reporting_delivery_method import ( - ReportingDeliveryMethod, - ReportingDeliveryMethod1, - ReportingDeliveryMethod2, - ReportingDeliveryMethod3, - ReportingOrchestration, -) -from adcp.types.domains.core.reporting_delivery_offering import ( - DestinationMode, - ProducerIdentity, - ReportingDeliveryOffering, - ReportingDeliveryPattern, - ReportingFeedPurpose, - ReportingProfile, -) -from adcp.types.domains.core.reporting_delivery_offering_id import ReportingDeliveryOfferingId -from adcp.types.domains.core.reporting_delivery_ready_webhook import ( - Readiness, - ReportingDeliveryReadyWebhook, -) -from adcp.types.domains.core.reporting_file_compression import ReportingFileCompression -from adcp.types.domains.core.reporting_file_entry import ReportingFileEntry -from adcp.types.domains.core.reporting_file_manifest import ReportingFileManifest -from adcp.types.domains.core.reporting_file_object_ref import ReportingFileObjectReference -from adcp.types.domains.core.reporting_ledger_changed_webhook import ReportingLedgerChangedWebhook -from adcp.types.domains.core.reporting_materialization import ReportingMaterialization -from adcp.types.domains.core.reporting_native_version_ref import ReportingNativeVersionReference -from adcp.types.domains.core.reporting_obligation import ( - ProductionStatus, - ReconciliationStatus, - ReportingObligation, -) -from adcp.types.domains.core.reporting_receipt import RejectionCode, ReportingReceipt -from adcp.types.domains.core.reporting_reconciliation_mode import ReportingReconciliationMode -from adcp.types.domains.core.reporting_reliability_statistics import ( - AdjustmentMagnitudeItem, - Basis, - LatencyPercentiles, - ReportingReliabilityMeasurementPeriod, - ReportingReliabilityStatistics, -) -from adcp.types.domains.core.reporting_report_definition import ( - Aggregation, - Calendar, - ContractVersion, - FinalityPolicies, - FinalityPolicies1, - FinalityPolicies2, - ReportCalendarTimezoneBasis, - ReportingReportDefinition, - RestatementPolicy, -) -from adcp.types.domains.core.reporting_resource import ( - Immutability, - ReportingReaderCompatibilityItem, - ReportingResource, -) -from adcp.types.domains.core.reporting_revision import ( - DataThroughPrecision, - FinalityBasis, - ReportingRevision, -) -from adcp.types.domains.core.reporting_schedule import ReportingSchedule, ReportingScheduleAlignment -from adcp.types.domains.core.reporting_schedule_offering import ( - PeriodAnchorPolicy, - PeriodTimezonePolicy, - ReportingScheduleOffering, -) -from adcp.types.domains.core.reporting_status_changed_webhook import ( - IssueId, - ReportingStatusChangedWebhook, -) -from adcp.types.domains.core.reporting_status_issue import ( - IssueState, - RecommendedAction, - ReportingStatusIssue, - ReportingStatusSeverity, - ResponsibleParty, -) -from adcp.types.domains.core.reporting_verification import ( - NativeCommitEvidence, - ObservedThrough, - PhysicalChecksums, - PhysicalChecksums1, - ReportingVerification, - VerificationPath, -) -from adcp.types.domains.core.reporting_verification_profile import ReportingVerificationProfile -from adcp.types.domains.core.reporting_verification_profile_set import ( - ReportingVerificationProfileSet, - ReportingVerificationProfileSetEnum, -) -from adcp.types.domains.core.reporting_webhook import ReportingFrequency, ReportingWebhook -from adcp.types.domains.core.reporting_write_destination import ( - ReportingWriteDestination, - ReportingWriteDestination1, - ReportingWriteDestination2, -) -from adcp.types.domains.core.representation_destination import RepresentationDestination -from adcp.types.domains.core.representation_rejection import RepresentationRejection -from adcp.types.domains.core.representation_selection import RepresentationSelection, ResolvedBy -from adcp.types.domains.core.requirements.asset_requirements import AssetRequirements -from adcp.types.domains.core.requirements.audio_asset_requirements import ( - AudioAssetRequirements, - SampleRate, -) -from adcp.types.domains.core.requirements.catalog_field_binding import ( - AssetPoolBinding, - CatalogFieldBinding, - CatalogFieldBinding1, - PerItemBindings, - ScalarBinding, -) -from adcp.types.domains.core.requirements.catalog_requirements import CatalogRequirements -from adcp.types.domains.core.requirements.css_asset_requirements import CssAssetRequirements -from adcp.types.domains.core.requirements.daast_asset_requirements import DaastAssetRequirements -from adcp.types.domains.core.requirements.html_asset_requirements import ( - HtmlAssetRequirements, - Sandbox, -) -from adcp.types.domains.core.requirements.image_asset_requirements import ( - Bleed, - Bleed1, - ImageAssetRequirements, - PixelRatio, -) -from adcp.types.domains.core.requirements.javascript_asset_requirements import ( - JavascriptAssetRequirements, - ModuleType, -) -from adcp.types.domains.core.requirements.markdown_asset_requirements import ( - MarkdownAssetRequirements, -) -from adcp.types.domains.core.requirements.offering_asset_constraint import OfferingAssetConstraint -from adcp.types.domains.core.requirements.text_asset_requirements import TextAssetRequirements -from adcp.types.domains.core.requirements.url_asset_requirements import ( - Protocol, - UrlAssetRequirements, -) -from adcp.types.domains.core.requirements.vast_asset_requirements import VastAssetRequirements -from adcp.types.domains.core.requirements.video_asset_requirements import ( - AudioCodec, - AudioSampleRate, - FrameRate, - VideoAssetRequirements, -) -from adcp.types.domains.core.requirements.webhook_asset_requirements import WebhookAssetRequirements -from adcp.types.domains.core.response import ProtocolResponse -from adcp.types.domains.core.response_payload_jws_envelope import ( - ResponsePayload, - ResponsePayloadJwsEnvelope, -) -from adcp.types.domains.core.rights_attestation_evaluation import ( - Issuer5, - Issuer6, - Reference, - RightsAttestationEvaluation, - Subject10, - Subject19, -) -from adcp.types.domains.core.rights_constraint import ( - ApprovalStatus, - ExcludedCountry, - GrantStatus, - Issuer8, - Issuer9, - Restriction, - RightsAgent, - RightsConstraint, - Subject20, - Subject29, -) -from adcp.types.domains.core.seller_agent_ref import SellerAgentReference -from adcp.types.domains.core.signal_coverage_forecast import ( - BucketCompleteness, - BucketSemantics, - Point, - SignalCoverageForecast, -) -from adcp.types.domains.core.signal_definition import AudienceScope, SignalDefinition, Tag -from adcp.types.domains.core.signal_definition_enrichment import SignalDefinitionEnrichment -from adcp.types.domains.core.signal_filters import SignalFilters -from adcp.types.domains.core.signal_id import SignalId8, SignalId9 -from adcp.types.domains.core.signal_listing import SignalListing -from adcp.types.domains.core.signal_modeling_disclosure import Audience, SignalModelingDisclosure -from adcp.types.domains.core.signal_pricing import ( - VendorPricing, - VendorPricing1, - VendorPricing2, - VendorPricing3, - VendorPricing4, - VendorPricing5, -) -from adcp.types.domains.core.signal_pricing_option import SignalPricingOption -from adcp.types.domains.core.signal_ref import SignalRef, SignalRef1, SignalRef2, SignalRef3 -from adcp.types.domains.core.signal_selection_group_rule import SignalSelectionGroupRule -from adcp.types.domains.core.signal_targeting import ( - SignalTargeting, - SignalTargeting1, - SignalTargeting2, - SignalTargeting3, -) -from adcp.types.domains.core.signal_targeting_expression import ( - SignalTargetingExpression, - SignalTargetingExpression1, - SignalTargetingExpression2, - SignalTargetingExpression3, -) -from adcp.types.domains.core.signal_targeting_rules import ResolutionModel, SignalTargetingRules -from adcp.types.domains.core.sla_window import SlaWindow -from adcp.types.domains.core.special import Special -from adcp.types.domains.core.spot_reporting_capability import SpotReportingCapability -from adcp.types.domains.core.start_timing import StartTiming -from adcp.types.domains.core.store_item import StoreItem -from adcp.types.domains.core.talent import Talent -from adcp.types.domains.core.targeting import AgeRestriction, TargetingOverlay -from adcp.types.domains.core.targeting_input import TargetingOverlayInput -from adcp.types.domains.core.targeting_modification import ( - Path, - Selector, - TargetingModification, - TargetingModification1, - TargetingModification2, -) -from adcp.types.domains.core.targeting_overlay_requirements import ( - BrowserRequirement, - BrowserRequirement1, - DaypartRequirement, - DaypartRequirement1, - KeywordRequirement, - KeywordRequirement1, - MetroRequirement, - MetroRequirement1, - Required, - TargetingOverlayRequirements, -) -from adcp.types.domains.core.targeting_overlay_support import ( - BrowserSupport, - BrowserSupport1, - CountrySupport, - CountrySupport1, - DaypartSupport, - DaypartSupport1, - IanaTimezones, - KeywordSupport, - KeywordSupport1, - MetroSupport, - MetroSupport1, - PlaceCatalogSupport, - PlaceSupport, - Supported, - TargetingOverlaySupport, -) -from adcp.types.domains.core.targeting_unknown_age_eligibility_constraint import ( - TargetingUnknownAgeEligibilityConstraint, -) -from adcp.types.domains.core.targeting_verified_age_basis_constraint import ( - TargetingVerifiedAgeBasisConstraint, -) -from adcp.types.domains.core.tasks_get_request import TasksGetRequest -from adcp.types.domains.core.tasks_get_response import ( - Details, - HistoryItem, - Progress, - TasksGetResponse, -) -from adcp.types.domains.core.tasks_list_request import Filters, Sort, TasksListRequest -from adcp.types.domains.core.tasks_list_response import ( - DomainBreakdown, - QuerySummary, - SortApplied, - TasksListResponse, -) -from adcp.types.domains.core.tracker_execution_contract import TrackerExecutionContract -from adcp.types.domains.core.tracker_execution_selector import ( - TrackerExecutionSelector, - TrackerExecutionSelector1, - TrackerExecutionSelector2, - TrackerExecutionSelector3, -) -from adcp.types.domains.core.transformer import ( - BrandAgent, - InputFormat, - InputFormat1, - InputFormat10, - InputFormat11, - InputFormat12, - InputFormat13, - InputFormat14, - InputFormat15, - InputFormat16, - InputFormat17, - InputFormat18, - InputFormat19, - InputFormat2, - InputFormat20, - InputFormat21, - InputFormat22, - InputFormat23, - InputFormat24, - InputFormat25, - InputFormat26, - InputFormat27, - InputFormat28, - InputFormat29, - InputFormat3, - InputFormat30, - InputFormat31, - InputFormat32, - InputFormat33, - InputFormat4, - InputFormat5, - InputFormat6, - InputFormat7, - InputFormat8, - InputFormat9, - Multiplicity, - OutputCapabilityId, - Transformer, - VariantDimension, - VoiceSynthesisRefItem, -) -from adcp.types.domains.core.transformer_param import Option, TransformerParam, ValueSource -from adcp.types.domains.core.truncation_sentinel import FieldTruncation, TruncationSentinel -from adcp.types.domains.core.user_match import UserMatch -from adcp.types.domains.core.vast_media_file_requirements import MimeType, VastMediafileRequirements -from adcp.types.domains.core.vast_tracker_constraints import ( - VastEvent, - VastOffset, - VastTarget, - VastTrackerConstraints, - VastVersions, -) -from adcp.types.domains.core.vehicle_item import ( - BodyStyle, - Condition, - FuelType, - Mileage, - Transmission, - VehicleItem, -) -from adcp.types.domains.core.vendor_metric_id import VendorMetricId -from adcp.types.domains.core.vendor_metric_optimization import VendorMetricOptimization -from adcp.types.domains.core.vendor_metric_optimization_supported_metric import ( - VendorMetricOptimizationSupportedMetric, -) -from adcp.types.domains.core.vendor_metric_value import VendorMetricValue -from adcp.types.domains.core.vendor_pricing_option import ( - AppliesToOutputCapabilityId, - VendorPricingOption, - VendorPricingOption1, - VendorPricingOption10, - VendorPricingOption11, - VendorPricingOption2, - VendorPricingOption3, - VendorPricingOption4, - VendorPricingOption5, - VendorPricingOption6, - VendorPricingOption7, - VendorPricingOption8, - VendorPricingOption9, -) -from adcp.types.domains.core.verification_token_claims import ( - AgenticadvertisingOrgVerificationTokenClaims, - VerificationTokenGradingProfile, - VerificationTokenMode, -) -from adcp.types.domains.core.version_envelope import AdcpVersionEnvelope -from adcp.types.domains.core.warning import Warning -from adcp.types.domains.core.warning_resource import WarningAffectedResource -from adcp.types.domains.core.webhook_activity_record import WebhookActivityRecord -from adcp.types.domains.core.webhook_challenge import WebhookChallenge -from adcp.types.domains.core.webhook_challenge_response import WebhookChallengeResponse -from adcp.types.domains.core.wholesale_feed_event import ( - AffectedEntityType, - AppliesTo, - AppliesTo1, - AppliesTo2, - Payload17, - Payload18, - Payload20, - Payload21, - Payload22, - Payload23, - Payload24, - Payload25, - RemovalReason, - Signal, - WholesaleFeedEvent, - WholesaleFeedEvent1, - WholesaleFeedEvent2, - WholesaleFeedEvent3, - WholesaleFeedEvent4, - WholesaleFeedEvent5, - WholesaleFeedEvent6, - WholesaleFeedEvent7, - WholesaleFeedEvent8, - WholesaleFeedEvent9, -) -from adcp.types.domains.core.wholesale_feed_webhook import ( - CacheScope, - NotificationType, - WholesaleFeedWebhook, -) -from adcp.types.domains.core.x_entity_types import XEntityTypes +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.core.acceptance_policy_profile_ids import ( + AcceptancePolicyProfileId, + AcceptancePolicyProfileIds, + ) + from adcp.types.domains.core.account import ( + Account, + Compression, + CreditLimit, + GovernanceAgent, + ReportingBucket, + ) + from adcp.types.domains.core.account_authorization import ( + AccountAuthorization, + AllowedTask, + ScopeName, + ) + from adcp.types.domains.core.account_change import ( + AccountChange, + Actor, + Repair, + UnavailableReason, + ) + from adcp.types.domains.core.account_change_recorded_webhook import AccountChangeRecordedWebhook + from adcp.types.domains.core.account_identity_change import ( + AccountIdentityChange, + AccountIdentityChange1, + AccountIdentityChange2, + ) + from adcp.types.domains.core.account_identity_change_preview import ( + AccountIdentityChangePreview, + AccountIdentityChangePreview1, + AccountIdentityChangePreview2, + AccountIdentityChangePreview3, + BlockedImpacts, + Blocker, + Effect1, + Impact, + NonblockingImpact, + NonblockingImpacts, + ) + from adcp.types.domains.core.account_ref import ( + AccountReference, + AccountReference1, + AccountReference2, + ) + from adcp.types.domains.core.account_status_changed_webhook import AccountStatusChangedWebhook + from adcp.types.domains.core.account_timezone_capability import ( + AccountSelection, + AccountTimezoneCapability, + SupportedTimezone, + ) + from adcp.types.domains.core.account_with_authorization import AccountWithAuthorization + from adcp.types.domains.core.activation_key import ActivationKey, ActivationKey1, ActivationKey2 + from adcp.types.domains.core.ad_inventory_config import AdInventoryConfiguration + from adcp.types.domains.core.agent_encryption_key import AgentEncryptionKey + from adcp.types.domains.core.agent_notification_config import AgentNotificationConfig + from adcp.types.domains.core.agent_notification_config_state import AgentNotificationConfigState + from adcp.types.domains.core.agent_reporting_destination import ( + AcceptedFormat, + AgentReportingDestination, + AgentReportingDestination1, + AgentReportingDestination2, + AgentReportingDestination3, + ) + from adcp.types.domains.core.agent_reporting_destination_state import ( + AgentReportingDestinationState, + PriorDestinationRef, + ) + from adcp.types.domains.core.agent_signing_key import AgentSigningKey + from adcp.types.domains.core.agent_webhook_challenge import AgentWebhookChallenge + from adcp.types.domains.core.app_item import AppItem, Platform + from adcp.types.domains.core.applicable_package_id import ApplicablePackageId + from adcp.types.domains.core.asset_group_vocabulary import AdcpAssetGroupVocabularyRegistry + from adcp.types.domains.core.assets.asset_union import ( + AssetVariant, + AudioChannelLayout, + C2paWatermarkAction, + CatalogType, + ContentIdType, + DaastAsset1, + DaastAsset2, + DaastTrackingEvent, + DaastVersion, + DigitalSourceType, + DisclosurePersistence, + DisclosurePosition, + DisplayTagAsset1, + DisplayTagAsset2, + DisplayTagAsset3, + EmbeddedProvenanceMethod, + Ext, + FeedFormat, + FieldModel, + FrameRateType, + GopType, + HttpMethod, + JavascriptModuleType, + Jurisdiction2, + Location1, + Location2, + Location4, + Location5, + Location6, + Location8, + Location9, + MacroBearingUrl1, + MacroBearingUrl2, + MacroDeclaration1, + MacroDeclaration10, + MacroDeclaration2, + MacroDeclaration3, + MacroDeclaration4, + MacroDeclaration5, + MacroDeclaration6, + MacroDeclaration7, + MacroDeclaration8, + MacroDeclaration9, + MacroDeclarationModel, + MacroDialect, + MacroMappingStatus, + MacroProcessingOperation, + MacroResolver, + MacroValueContext, + MarkdownFlavor, + MoovAtomPosition, + PixelTrackingEvent, + PlatformExtensionRef, + PublishedPostAsset1, + PublishedPostAsset2, + ReferenceAuthorization1, + Role2, + ScanType, + Target1, + UniversalMacro, + UpdateFrequency, + UrlAssetType, + VastAsset1, + VastAsset2, + VastTrackingEvent, + VastVersion, + VerifyAgent1, + WatermarkMediaType, + WebhookResponseType, + WebhookSecurityMethod, + ) + from adcp.types.domains.core.assets.audio_asset import BitDepth + from adcp.types.domains.core.assets.daast_asset import ( + DaastAsset3, + DaastAsset4, + Location13, + MacroDeclaration12, + ) + from adcp.types.domains.core.assets.display_tag_asset import ( + DisplayTagAsset4, + DisplayTagAsset5, + DisplayTagAsset6, + Location17, + Location18, + MacroDeclaration15, + MacroDeclaration16, + ) + from adcp.types.domains.core.assets.vast_asset import ( + Location26, + MacroDeclaration20, + VastAsset3, + VastAsset4, + ) + from adcp.types.domains.core.async_response_data import AdcpAsyncResponseData + from adcp.types.domains.core.async_response_refs.creative.get_creative_features_async_response_submitted import ( + GetCreativeFeaturesSubmitted, + ) + from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_input_required import ( + SyncCreativesInputRequired, + ) + from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_submitted import ( + SyncCreativesSubmitted, + ) + from adcp.types.domains.core.async_response_refs.creative.sync_creatives_async_response_working import ( + SyncCreativesWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_input_required import ( + AcceptProposalInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_submitted import ( + AcceptProposalSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.accept_proposal_async_response_working import ( + AcceptProposalWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_input_required import ( + BuildCreativeInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_submitted import ( + BuildCreativeSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.build_creative_async_response_working import ( + BuildCreativeWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_input_required import ( + BuyProductsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_submitted import ( + BuyProductsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.buy_products_async_response_working import ( + BuyProductsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_input_required import ( + ControlMediaBuyInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_submitted import ( + ControlMediaBuySubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.control_media_buy_async_response_working import ( + ControlMediaBuyWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_input_required import ( + CreateMediaBuyInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_submitted import ( + CreateMediaBuySubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.create_media_buy_async_response_working import ( + CreateMediaBuyWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_input_required import ( + DeclineProposalsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_submitted import ( + DeclineProposalsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.decline_proposals_async_response_working import ( + DeclineProposalsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_input_required import ( + GetProductsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_submitted import ( + GetProductsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.get_products_async_response_working import ( + GetProductsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_input_required import ( + RefineProposalsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_submitted import ( + RefineProposalsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.refine_proposals_async_response_working import ( + RefineProposalsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_input_required import ( + RequestProposalsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_submitted import ( + RequestProposalsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.request_proposals_async_response_working import ( + RequestProposalsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_input_required import ( + SyncCatalogsInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_submitted import ( + SyncCatalogsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.sync_catalogs_async_response_working import ( + SyncCatalogsWorking, + ) + from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_input_required import ( + UpdateMediaBuyInputRequired, + ) + from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_submitted import ( + UpdateMediaBuySubmitted, + ) + from adcp.types.domains.core.async_response_refs.media_buy.update_media_buy_async_response_working import ( + UpdateMediaBuyWorking, + ) + from adcp.types.domains.core.async_response_refs.signals.get_signals_async_response_submitted import ( + GetSignalsSubmitted, + ) + from adcp.types.domains.core.async_response_refs.signals.get_signals_async_response_working import ( + GetSignalsWorking, + ) + from adcp.types.domains.core.attestation_capabilities import ( + AcceptedIssuer, + AttestationCapabilities, + CredentialOrigin, + Resolver, + SupportedDeliveryMethod, + ) + from adcp.types.domains.core.attestation_evaluation import Outcome + from adcp.types.domains.core.attestation_issuer import ( + AttestationIssuer, + AttestationIssuer1, + AttestationIssuer2, + AttestationIssuer3, + ) + from adcp.types.domains.core.attestation_reference import ( + AttestationReference, + EmbeddedCredential, + Locator, + Locator1, + ValidityHint, + ) + from adcp.types.domains.core.attestation_subject import ( + AttestationSubject, + AttestationSubject1, + AttestationSubject2, + AttestationSubject3, + ) + from adcp.types.domains.core.attribution_window import AttributionWindow + from adcp.types.domains.core.audience_activation_method import ( + AudienceActivationMethod, + AudienceActivationMethod1, + AudienceActivationMethod2, + AudienceActivationMethod3, + AudienceActivationMethod4, + AudienceActivationMethod5, + AudienceActivationMethod6, + Cloud, + ConsumerIdentity, + ) + from adcp.types.domains.core.audience_characteristic import ( + AudienceCharacteristic, + AudienceCharacteristicBranches, + AudienceCharacteristicRange, + AudienceCharacteristicValue, + ) + from adcp.types.domains.core.audience_evidence import ( + AudienceEvidence, + Subject11, + Subject12, + Subject13, + Subject14, + Subject15, + Subject16, + Subject17, + Subject2, + Subject21, + Subject22, + Subject23, + Subject24, + Subject25, + Subject26, + Subject27, + Subject3, + Subject31, + Subject32, + Subject33, + Subject34, + Subject35, + Subject36, + Subject37, + ) + from adcp.types.domains.core.audience_evidence_pin import AudienceEvidencePin + from adcp.types.domains.core.audience_evidence_requirements import AudienceEvidenceRequirements + from adcp.types.domains.core.audience_evidence_selection import AudienceEvidenceSelection + from adcp.types.domains.core.audience_member import AudienceMember + from adcp.types.domains.core.audience_selector import ( + AudienceSelector, + AudienceSelector1, + AudienceSelector2, + AudienceSelector3, + AudienceSelector4, + ) + from adcp.types.domains.core.audience_source import ( + AudienceSource, + AudienceSource1, + AudienceSource2, + ) + from adcp.types.domains.core.authorized_agent_base import AuthorizedAgentBaseFields + from adcp.types.domains.core.bidding_policy import ( + BiddingPolicy, + CostPer, + Roas, + Strength, + Strength1, + ) + from adcp.types.domains.core.bidding_policy_capability import ( + BiddingPolicyCapability, + CostPerStrength, + MaxBidWithCostPer, + MaxBidWithRoas, + PolicyProfile, + RoasStrength, + ScopeCapability, + ) + from adcp.types.domains.core.brand_id import BrandId + from adcp.types.domains.core.brand_key import BrandKey + from adcp.types.domains.core.brand_ref import ( + BrandKitOverride, + BrandReference, + Colors, + DataSubjectContestation, + ) + from adcp.types.domains.core.brand_response_authorization_result import ( + BrandResponseAuthorizationResult, + BrandResponseAuthorizationResult1, + BrandResponseAuthorizationResult2, + Trust, + ) + from adcp.types.domains.core.budget_allocation import ( + BudgetAllocation, + BudgetAllocation1, + BudgetAllocation2, + OptimizationGoal1, + OptimizationGoal2, + OptimizationGoal3, + OptimizationGoal4, + OptimizationGoal5, + OptimizationGoal6, + OptimizationGoal7, + Target3, + Target4, + Target5, + Target6, + Target7, + Target8, + ) + from adcp.types.domains.core.business_entity import Bank, BusinessEntity, Contact + from adcp.types.domains.core.cancellation_policy import CancellationFee, CancellationPolicy + from adcp.types.domains.core.canonical_account_ref import ( + CanonicalAccountReference, + CanonicalAccountReference1, + CanonicalAccountReference2, + ) + from adcp.types.domains.core.canonical_audience_evidence import ( + AttestationDigest, + CanonicalAudienceEvidence, + ) + from adcp.types.domains.core.canonical_audience_evidence_selection import ( + CanonicalAudienceEvidenceSelection, + VerifiedAttestationDigest, + ) + from adcp.types.domains.core.canonical_budget_allocation import ( + CanonicalBudgetAllocation, + CanonicalBudgetAllocation1, + CanonicalBudgetAllocation2, + ) + from adcp.types.domains.core.canonical_delivery_forecast import CanonicalDeliveryForecast + from adcp.types.domains.core.canonical_forecast_point import CanonicalForecastPoint + from adcp.types.domains.core.canonical_forecast_vendor_metric_value import ( + CanonicalForecastVendorMetricValue, + ) + from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind + from adcp.types.domains.core.canonical_format_option import CanonicalFormatOption, FormatKind + from adcp.types.domains.core.canonical_measurement_terms import CanonicalMeasurementTerms + from adcp.types.domains.core.canonical_media_buy_action import ( + Action3, + Action4, + CanonicalMediaBuyAction, + CanonicalMediaBuyAction1, + CanonicalMediaBuyAction2, + CanonicalMediaBuyAction3, + ) + from adcp.types.domains.core.canonical_media_buy_action_fields import ( + CanonicalMediaBuyActionFields, + ) + from adcp.types.domains.core.canonical_media_buy_features import CanonicalMediaBuyFeatures + from adcp.types.domains.core.canonical_metric_qualifier import CanonicalMetricQualifier + from adcp.types.domains.core.canonical_optimization_goal import ( + CanonicalOptimizationGoal, + CanonicalOptimizationGoal1, + CanonicalOptimizationGoal2, + CanonicalOptimizationGoal3, + Target10, + Target11, + ) + from adcp.types.domains.core.canonical_performance_standard import CanonicalPerformanceStandard + from adcp.types.domains.core.canonical_placement import ( + CanonicalDoohPlacementAttributes, + CanonicalDoohScreenResolution, + CanonicalProductPlacement, + CanonicalProductPlacement1, + CanonicalProductPlacement2, + ) + from adcp.types.domains.core.canonical_pricing_option import ( + CanonicalPricingOption, + PricingModel, + ) + from adcp.types.domains.core.canonical_product import ( + CanonicalProduct, + PublisherProperty1, + PublisherProperty2, + PublisherProperty3, + PublisherProperty4, + PublisherProperty5, + PublisherProperty6, + PublisherProperty7, + ) + from adcp.types.domains.core.canonical_product_action import CanonicalProductAction + from adcp.types.domains.core.canonical_projection_ref import ( + AssetSource, + CanonicalProjectionReference, + ) + from adcp.types.domains.core.canonical_projection_slot_override import ( + CanonicalProjectionSlotOverride, + ) + from adcp.types.domains.core.canonical_proposal import CanonicalProposal, ProposalKind + from adcp.types.domains.core.canonical_reporting_capabilities import ( + CanonicalReportingCapabilities, + ) + from adcp.types.domains.core.canonical_reporting_commitment import ( + CanonicalReportingCommitment, + CanonicalReportingCommitment1, + CanonicalReportingCommitment2, + ) + from adcp.types.domains.core.canvas_constraint import CanvasConstraint, Constraint + from adcp.types.domains.core.capabilities_changed_webhook import CapabilitiesChangedWebhook + from adcp.types.domains.core.catalog_item_availability_error import CatalogItemAvailabilityError + from adcp.types.domains.core.catalog_item_availability_ref import ( + CatalogItemAvailabilityReference, + ) + from adcp.types.domains.core.catalog_item_availability_state import CatalogItemAvailabilityState + from adcp.types.domains.core.catalog_item_availability_update import ( + CatalogItemAvailabilityUpdate, + ) + from adcp.types.domains.core.catalog_item_availability_update_result import ( + CatalogItemAvailabilityUpdateResult, + ) + from adcp.types.domains.core.catalog_item_delivery_metrics import CatalogItemDeliveryMetrics + from adcp.types.domains.core.catalog_item_reference_not_found_error import ( + CatalogItemReferenceNotFoundError, + ) + from adcp.types.domains.core.catalog_selection import CatalogSelection + from adcp.types.domains.core.catchment import ( + Catchment, + CatchmentBranches, + CatchmentGeometry, + CatchmentRadius, + CatchmentTransportModeTravelTime, + ) + from adcp.types.domains.core.collection import Collection, RelatedCollection + from adcp.types.domains.core.collection_delivery_metrics import CollectionDeliveryMetrics + from adcp.types.domains.core.collection_distribution import CollectionDistribution + from adcp.types.domains.core.collection_list_ref import CollectionListReference + from adcp.types.domains.core.collection_property_delivery_metrics import ( + CollectionPropertyDeliveryMetrics, + ) + from adcp.types.domains.core.collection_ref import CollectionReference + from adcp.types.domains.core.collection_selection import ( + CollectionSelection, + CollectionSelection1, + CollectionSelection2, + ) + from adcp.types.domains.core.collection_selector import CollectionSelector + from adcp.types.domains.core.committed_metric import ( + CommittedMetric, + CommittedMetric1, + CommittedMetric2, + Qualifier1, + QualifierModel, + ) + from adcp.types.domains.core.compact_task_input_required import CompactTaskInputRequired + from adcp.types.domains.core.compact_task_submitted import CompactTaskSubmitted + from adcp.types.domains.core.compact_task_working import CompactTaskWorking + from adcp.types.domains.core.content_rating import ContentRating + from adcp.types.domains.core.context import ContextObject + from adcp.types.domains.core.creative_approval_scope import ScopedCreativeApproval + from adcp.types.domains.core.creative_asset import ( + CreativeAsset, + CreativeAssetBranches, + CreativeAssetV1CreativeNamedFormatReference, + CreativeAssetV2CreativeCanonicalFormatKind, + Input, + ) + from adcp.types.domains.core.creative_assets import CreativeAssets, CreativeAssets1 + from adcp.types.domains.core.creative_assignment import CreativeAssignment, RotationMode + from adcp.types.domains.core.creative_consumption import CreativeConsumption + from adcp.types.domains.core.creative_delivery_metrics import CreativeDeliveryMetrics + from adcp.types.domains.core.creative_filters import CreativeFilters + from adcp.types.domains.core.creative_item import CreativeItem, CreativeItem1, CreativeItem2 + from adcp.types.domains.core.creative_locale_policy import CreativeLocalePolicy + from adcp.types.domains.core.creative_localization import CreativeLocalization, TargetVariant + from adcp.types.domains.core.creative_localization_readback import ( + CreativeLocalizationReadback, + ResolvedAssets, + ResolvedAssets1, + Variants, + Variants1, + Variants2, + ) + from adcp.types.domains.core.creative_manifest import ( + CreativeManifest, + CreativeManifestBranches, + CreativeManifestCanonicalFormatManifest, + CreativeManifestNamedFormatManifest, + ) + from adcp.types.domains.core.creative_operation_format_declaration import ( + CreativeOperationFormatDeclaration, + ) + from adcp.types.domains.core.creative_policy import CreativePolicy, ProvenanceRequirements + from adcp.types.domains.core.creative_representation import CreativeRepresentation + from adcp.types.domains.core.creative_representation_set import CreativeRepresentationSet + from adcp.types.domains.core.creative_revision_id import CreativeRevisionId + from adcp.types.domains.core.creative_variable import CreativeVariable, VariableType + from adcp.types.domains.core.creative_variant import ( + Artifact, + CreativeVariant, + GenerationContext, + ) + from adcp.types.domains.core.daast_tracker_constraints import ( + DaastEvent, + DaastOffset, + DaastTarget, + DaastTrackerConstraints, + DaastVersions, + ) + from adcp.types.domains.core.data_provider_signal_selector import ( + DataProviderSignalSelector, + DataProviderSignalSelector1, + DataProviderSignalSelector2, + DataProviderSignalSelector3, + SignalTag, + ) + from adcp.types.domains.core.date_range import DateRange + from adcp.types.domains.core.datetime_range import DatetimeRange + from adcp.types.domains.core.daypart_target import DaypartTarget + from adcp.types.domains.core.deadline_policy import DeadlinePolicy, MaterialStage + from adcp.types.domains.core.delivery_breakdown_controls import DeliveryBreakdownControls + from adcp.types.domains.core.delivery_forecast import DeliveryForecast + from adcp.types.domains.core.delivery_metric_aggregate import ( + DeliveryMetricAggregate, + DeliveryMetricAggregate1, + DeliveryMetricAggregate2, + Field0, + Qualifier3, + ) + from adcp.types.domains.core.delivery_metrics import ( + ByActionSourceItem, + ByEventTypeItem, + DeliveryMetrics, + DoohMetrics, + DoohMetrics1, + EstimationBasis, + OohMetrics, + Panel, + Posting, + QuartileData, + ReachWindow, + TimeBasedView, + VenueBreakdownItem, + Viewability1, + ViewedSecondsHistogramItem, + ViewedSecondsPercentiles, + ) + from adcp.types.domains.core.delivery_provider import DeliveryProvider + from adcp.types.domains.core.delivery_recipient import DeliveryRecipient + from adcp.types.domains.core.demographic_age_range import DemographicAgeRange + from adcp.types.domains.core.demographic_predicate import DemographicPredicate + from adcp.types.domains.core.demographic_reporting_capability import ( + DemographicReportingCapability, + ReportingMode, + ) + from adcp.types.domains.core.demographic_targeting_capability import ( + DemographicTargetingCapability, + ExecutionMode, + UnknownHandling, + ) + from adcp.types.domains.core.demographic_targeting_intent import DemographicTargetingIntent + from adcp.types.domains.core.demographic_targeting_resolution import ( + DemographicTargetingResolution, + Execution, + Execution1, + Execution2, + IntervalId, + ) + from adcp.types.domains.core.deployment import Deployment, Deployment1, Deployment2 + from adcp.types.domains.core.destination import Destination1, Destination2 + from adcp.types.domains.core.destination_item import DestinationItem, DestinationType + from adcp.types.domains.core.diagnostic_issue import DiagnosticIssue, Severity + from adcp.types.domains.core.downstream_connection_requirement import ( + ConnectionType, + DownstreamConnectionRequirement, + ResourceRef, + ) + from adcp.types.domains.core.duration import Duration + from adcp.types.domains.core.education_item import DegreeType, EducationItem, Level, Modality + from adcp.types.domains.core.error import BuyerReason, DiscriminatorItem, Issue, Recovery + from adcp.types.domains.core.evaluator_spec import ( + EvalBudget, + EvaluatorSpec, + EvaluatorSpec1, + EvaluatorSpec2, + EvaluatorSpec3, + Exemplars, + FeatureAgent, + RankByItem, + RankByItem1, + RankByItem2, + ) + from adcp.types.domains.core.event import Event + from adcp.types.domains.core.event_custom_data import Content, EventCustomData + from adcp.types.domains.core.event_source_health import Detail, EventSourceHealth + from adcp.types.domains.core.event_surface import Category, EventSurface + from adcp.types.domains.core.experimental_feature_id import ExperimentalFeatureId + from adcp.types.domains.core.ext import ExtensionObject + from adcp.types.domains.core.feature_requirement import FeatureRequirement, IfNotCovered + from adcp.types.domains.core.flight_item import FlightItem + from adcp.types.domains.core.forecast_dimension_audience import AudienceForecastDimension + from adcp.types.domains.core.forecast_dimension_device_platform import ( + DevicePlatformForecastDimension, + ) + from adcp.types.domains.core.forecast_dimension_device_type import DeviceTypeForecastDimension + from adcp.types.domains.core.forecast_dimension_geo import GeoForecastDimension + from adcp.types.domains.core.forecast_dimension_placement import PlacementForecastDimension + from adcp.types.domains.core.forecast_dimension_signal import Presence, SignalForecastDimension + from adcp.types.domains.core.forecast_dimension_time import TimeForecastDimension + from adcp.types.domains.core.forecast_point import ForecastPoint, ViewableRate + from adcp.types.domains.core.forecast_point_dimensions import ForecastPointDimensions + from adcp.types.domains.core.forecast_range import ForecastRange + from adcp.types.domains.core.forecast_rate_range import ForecastRateRange + from adcp.types.domains.core.forecast_vendor_metric_value import ForecastVendorMetricValue + from adcp.types.domains.core.format import ( + Assets10, + Assets11, + Assets12, + Assets13, + Assets14, + Assets15, + Assets16, + Assets17, + Assets18, + Assets19, + Assets20, + Assets21, + Assets22, + Assets23, + Assets24, + Assets25, + Assets26, + Assets27, + Assets28, + Assets29, + Assets30, + Assets31, + Assets32, + Assets33, + Assets34, + Assets35, + Assets36, + Assets37, + Assets38, + Assets39, + Assets40, + Assets41, + Assets42, + Assets43, + Assets44, + Assets45, + Assets46, + Assets47, + Assets48, + Assets49, + Assets50, + Assets51, + Assets9, + Assets94, + BaseGroupAsset, + BaseIndividualAsset, + CanonicalParameters, + CanonicalParameters1, + CanonicalParameters10, + CanonicalParameters11, + CanonicalParameters12, + CanonicalParameters13, + CanonicalParameters14, + CanonicalParameters15, + CanonicalParameters16, + CanonicalParameters17, + CanonicalParameters18, + CanonicalParameters19, + CanonicalParameters2, + CanonicalParameters20, + CanonicalParameters21, + CanonicalParameters22, + CanonicalParameters23, + CanonicalParameters24, + CanonicalParameters25, + CanonicalParameters26, + CanonicalParameters27, + CanonicalParameters28, + CanonicalParameters29, + CanonicalParameters3, + CanonicalParameters30, + CanonicalParameters31, + CanonicalParameters32, + CanonicalParameters33, + CanonicalParameters4, + CanonicalParameters5, + CanonicalParameters6, + CanonicalParameters7, + CanonicalParameters8, + CanonicalParameters9, + Dimensions, + Dimensions1, + DisclosureCapability, + FormatAssetUnion, + FormatCard, + FormatCardDetailed, + GroupFormatAssetUnion, + Renders, + Renders1, + Responsive, + UnknownFormatAsset, + UnknownGroupAsset, + ) + from adcp.types.domains.core.format_id import FormatReferenceStructuredObject + from adcp.types.domains.core.format_option_ref import ( + FormatOptionReference, + FormatOptionReference1, + FormatOptionReference2, + ) + from adcp.types.domains.core.format_shape_vocabulary import AdcpFormatShapeVocabularyRegistry + from adcp.types.domains.core.frequency_cap import FrequencyCap + from adcp.types.domains.core.frequency_cap_constraints import FrequencyCapConstraints + from adcp.types.domains.core.frequency_cap_duration_unit import FrequencyCapDurationUnit + from adcp.types.domains.core.frequency_cap_impression_constraints import ( + AllowedValue, + FrequencyCapImpressionConstraints, + FrequencyCapImpressionConstraintsAllowedValues, + FrequencyCapImpressionConstraintsBranches, + FrequencyCapImpressionConstraintsMaximumMinimum, + ) + from adcp.types.domains.core.frequency_cap_interval_constraints import ( + AllowedInterval, + FrequencyCapIntervalConstraints, + FrequencyCapIntervalConstraintsAllowedIntervals, + FrequencyCapIntervalConstraintsBranches, + FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval, + ) + from adcp.types.domains.core.frequency_cap_requirements import FrequencyCapRequirements + from adcp.types.domains.core.generation_credential import GenerationCredential + from adcp.types.domains.core.geo_breakdown_support import GeographicBreakdownSupport + from adcp.types.domains.core.geo_delivery_metrics import GeoDeliveryMetrics + from adcp.types.domains.core.geo_metro import GeoMetro + from adcp.types.domains.core.geo_place_area import GeographicPlaceArea + from adcp.types.domains.core.geo_place_catalog_capability import ( + GeographicPlaceCatalogCapability, + SupportedVersion, + ) + from adcp.types.domains.core.geo_place_catalog_entry import ( + GeographicPlaceCatalogEntry, + ParentLabel, + ReplacedByValue, + ) + from adcp.types.domains.core.geo_place_requirement import ( + CatalogRequirement, + GeographicPlaceRequirement, + ) + from adcp.types.domains.core.geo_place_resolver import Auth, GeographicPlaceResolver + from adcp.types.domains.core.geo_place_support import GeographicPlaceSystemSupport + from adcp.types.domains.core.geo_place_system import ( + GeographicPlaceIdentifierSystem, + GeographicPlaceIdentifierSystem1, + GeographicPlaceIdentifierSystem2, + ) + from adcp.types.domains.core.geo_place_type import ( + GeographicPlaceType, + GeographicPlaceType1, + GeographicPlaceType2, + ) + from adcp.types.domains.core.geo_region_requirement import ( + Countries1, + GeographicRegionRequirement, + ) + from adcp.types.domains.core.geo_region_support import Countries3, GeographicRegionSupport + from adcp.types.domains.core.get_geo_place_resolution_request import ( + GetGeographicPlaceResolutionRequest, + GetGeographicPlaceResolutionRequestBranches, + GetGeographicPlaceResolutionRequestQ, + GetGeographicPlaceResolutionRequestValue, + ) + from adcp.types.domains.core.get_geo_place_resolution_response import ( + GetGeographicPlaceResolutionResponse, + ) + from adcp.types.domains.core.hotel_item import HotelItem + from adcp.types.domains.core.iana_timezone import IanaTimezoneIdentifier + from adcp.types.domains.core.impairment import Impairment, Transition + from adcp.types.domains.core.indicator import Indicator + from adcp.types.domains.core.indicator_bearing import IndicatorBearingResourceState + from adcp.types.domains.core.indicator_scope import IndicatorScope + from adcp.types.domains.core.indicators_changed_webhook import ( + IndicatorsChangedWebhook, + RelationshipKind, + ) + from adcp.types.domains.core.industry_identifier import IndustryIdentifier + from adcp.types.domains.core.insertion_order import ( + InsertionOrder, + PaymentTerms, + Terms, + TotalBudget, + ) + from adcp.types.domains.core.installment import DerivativeOf, Installment + from adcp.types.domains.core.installment_deadlines import InstallmentDeadlines + from adcp.types.domains.core.installment_delivery_metrics import InstallmentDeliveryMetrics + from adcp.types.domains.core.installment_property_delivery_metrics import ( + InstallmentPropertyDeliveryMetrics, + ) + from adcp.types.domains.core.installment_ref import InstallmentReference + from adcp.types.domains.core.inventory_list_application import ( + InventoryListApplication, + InventoryListApplication1, + InventoryListApplication2, + Summary, + Summary2, + ) + from adcp.types.domains.core.job_item import EmploymentType, ExperienceLevel, JobItem, Salary + from adcp.types.domains.core.keyword_delivery_metrics import KeywordDeliveryMetrics + from adcp.types.domains.core.limited_series import LimitedSeries + from adcp.types.domains.core.locale_tag import LanguageTag + from adcp.types.domains.core.localized_creative_asset import ( + LocalizedCreativeAsset, + LocalizedCreativeAsset1, + LocalizedCreativeAsset10, + LocalizedCreativeAsset11, + LocalizedCreativeAsset12, + LocalizedCreativeAsset13, + LocalizedCreativeAsset14, + LocalizedCreativeAsset15, + LocalizedCreativeAsset16, + LocalizedCreativeAsset17, + LocalizedCreativeAsset18, + LocalizedCreativeAsset19, + LocalizedCreativeAsset2, + LocalizedCreativeAsset20, + LocalizedCreativeAsset21, + LocalizedCreativeAsset22, + LocalizedCreativeAsset3, + LocalizedCreativeAsset4, + LocalizedCreativeAsset5, + LocalizedCreativeAsset7, + LocalizedCreativeAsset8, + LocalizedCreativeAsset9, + ) + from adcp.types.domains.core.macro_bearing_url import MacroBearingUrl3, MacroBearingUrl4 + from adcp.types.domains.core.macro_resolution_capability import ( + MacroProcessingCapability, + MappingStatus, + Operation, + ) + from adcp.types.domains.core.macro_resolution_result import MacroResolutionResult + from adcp.types.domains.core.material_deadline import MaterialDeadline + from adcp.types.domains.core.mcp_webhook_payload import McpWebhookPayload + from adcp.types.domains.core.measurement_readiness import MeasurementReadiness + from adcp.types.domains.core.measurement_terms import MeasurementTerms + from adcp.types.domains.core.measurement_window import MeasurementWindow + from adcp.types.domains.core.media_buy import MediaBuy + from adcp.types.domains.core.media_buy_available_action import MediaBuyAvailableAction + from adcp.types.domains.core.media_buy_available_action_id import MediaBuyAvailableActionId + from adcp.types.domains.core.media_buy_change_term_id import MediaBuyChangeTermId + from adcp.types.domains.core.media_buy_features import MediaBuyFeatures + from adcp.types.domains.core.media_buy_frequency_cap import MediaBuyFrequencyCap + from adcp.types.domains.core.media_buy_frequency_cap_capability import ( + MediaBuyFrequencyCapCapability, + ) + from adcp.types.domains.core.media_buy_frequency_cap_requirement import ( + MediaBuyFrequencyCapRequirement, + ) + from adcp.types.domains.core.media_buy_frequency_cap_support import MediaBuyFrequencyCapSupport + from adcp.types.domains.core.media_buy_legacy_terms_ref import MediaBuyTermsReference + from adcp.types.domains.core.media_buy_support import ProductMediaBuySupport + from adcp.types.domains.core.media_buy_support_requirements import ( + ProductMediaBuySupportRequirements, + ) + from adcp.types.domains.core.missing_metric import MissingMetric, MissingMetric1, MissingMetric2 + from adcp.types.domains.core.negative_keyword import NegativeKeyword + from adcp.types.domains.core.notification_config import NotificationConfig + from adcp.types.domains.core.offering import GeoTargets, Offering + from adcp.types.domains.core.offering_asset_group import Items, OfferingAssetGroup + from adcp.types.domains.core.operator_identity import OperatorIdentity + from adcp.types.domains.core.operator_unit import OperatorUnit + from adcp.types.domains.core.opportunity_context import ( + CloseReason, + Intent, + OpportunityContext, + Phase, + ) + from adcp.types.domains.core.optimization_goal import ( + OptimizationGoal10, + OptimizationGoal8, + OptimizationGoal9, + Target14, + Target15, + Target16, + Target17, + Target18, + Target19, + ) + from adcp.types.domains.core.outcome_measurement import OutcomeMeasurement + from adcp.types.domains.core.outcome_target_cost_per import OutcomeTargetCostPer + from adcp.types.domains.core.overlay import Bounds, Overlay, Visual + from adcp.types.domains.core.package import Package + from adcp.types.domains.core.package_delivery_metric_value import PackageDeliveryMetricValue + from adcp.types.domains.core.package_format_snapshot import ( + PackageFormatSnapshot, + PackageFormatSnapshot1, + PackageFormatSnapshot10, + PackageFormatSnapshot11, + PackageFormatSnapshot12, + PackageFormatSnapshot13, + PackageFormatSnapshot14, + PackageFormatSnapshot15, + PackageFormatSnapshot16, + PackageFormatSnapshot17, + PackageFormatSnapshot18, + PackageFormatSnapshot19, + PackageFormatSnapshot2, + PackageFormatSnapshot20, + PackageFormatSnapshot21, + PackageFormatSnapshot22, + PackageFormatSnapshot23, + PackageFormatSnapshot24, + PackageFormatSnapshot25, + PackageFormatSnapshot26, + PackageFormatSnapshot27, + PackageFormatSnapshot28, + PackageFormatSnapshot29, + PackageFormatSnapshot3, + PackageFormatSnapshot30, + PackageFormatSnapshot31, + PackageFormatSnapshot32, + PackageFormatSnapshot33, + PackageFormatSnapshot4, + PackageFormatSnapshot5, + PackageFormatSnapshot6, + PackageFormatSnapshot7, + PackageFormatSnapshot8, + PackageFormatSnapshot9, + ) + from adcp.types.domains.core.package_signal_targeting import ( + PackageSignalTargeting, + PackageSignalTargeting1, + PackageSignalTargeting2, + PackageSignalTargeting3, + PackageSignalTargeting4, + PackageSignalTargeting5, + PackageSignalTargeting6, + PackageSignalTargeting7, + ) + from adcp.types.domains.core.package_signal_targeting_group import ( + Operator, + PackageSignalTargetingGroup, + ) + from adcp.types.domains.core.package_signal_targeting_groups import PackageSignalTargetingGroups + from adcp.types.domains.core.package_targeting_resolution import PackageTargetingResolution + from adcp.types.domains.core.pagination_request import PaginationRequest + from adcp.types.domains.core.pagination_response import PaginationResponse + from adcp.types.domains.core.performance_feedback import ( + MeasurementPeriod, + Metric7, + PerformanceFeedback, + ) + from adcp.types.domains.core.performance_feedback_assertion import ( + ConfidenceInterval, + PerformanceFeedbackAssertion, + ) + from adcp.types.domains.core.performance_feedback_metric import ( + PerformanceFeedbackMetric, + PerformanceFeedbackMetric1, + PerformanceFeedbackMetric2, + ) + from adcp.types.domains.core.performance_standard import PerformanceStandard + from adcp.types.domains.core.placement import ( + Placement, + ProductDoohPlacementAttributes, + ProductDoohScreenResolution, + ) + from adcp.types.domains.core.placement_definition import ( + FormatOptions, + PlacementDefinition, + PublisherDoohPlacementAttributes, + PublisherDoohScreenResolution, + ) + from adcp.types.domains.core.placement_delivery_metrics import PlacementDeliveryMetrics + from adcp.types.domains.core.placement_evidence import PlacementEvidence + from adcp.types.domains.core.placement_identity import ( + PlacementIdentity, + PlacementIdentity1, + PlacementIdentity2, + ) + from adcp.types.domains.core.placement_presentation import ( + BoxDecoration, + Canvas, + Color, + CreativeSlot, + Fit, + ImageDecoration, + ImageRef, + Layer, + PlacementPresentationDocument, + Rectangle, + TextDecoration, + ) + from adcp.types.domains.core.placement_property_delivery_metrics import ( + PlacementPropertyDeliveryMetrics, + ) + from adcp.types.domains.core.placement_ref import PlacementReference + from adcp.types.domains.core.placement_selection import PlacementSelection1, PlacementSelection2 + from adcp.types.domains.core.planned_delivery import Geo, PlannedDelivery + from adcp.types.domains.core.platform_extension_ref import PlatformExtensionReference + from adcp.types.domains.core.positive_postal_area_support import PositivePostalAreaSupport + from adcp.types.domains.core.postal_area import ( + PostalArea, + PostalArea1, + PostalArea11, + PostalArea110, + PostalArea111, + PostalArea112, + PostalArea113, + PostalArea114, + PostalArea115, + PostalArea116, + PostalArea117, + PostalArea118, + PostalArea119, + PostalArea12, + PostalArea120, + PostalArea121, + PostalArea13, + PostalArea14, + PostalArea15, + PostalArea16, + PostalArea17, + PostalArea18, + PostalArea19, + PostalArea2, + System1, + System2, + System3, + System9, + ) + from adcp.types.domains.core.postal_area_support import ( + CAEnum, + GBEnum, + ME, + PostalAreaSupport, + PostalAreaSupportAdditionalPropertyEnum, + ) + from adcp.types.domains.core.postal_country_system import ( + PostalCountrySystem, + PostalCountrySystem1, + PostalCountrySystem10, + PostalCountrySystem2, + PostalCountrySystem3, + PostalCountrySystem4, + PostalCountrySystem5, + PostalCountrySystem6, + PostalCountrySystem7, + PostalCountrySystem8, + PostalCountrySystem9, + System11, + System12, + System13, + System19, + ) + from adcp.types.domains.core.presentation_ref import PlacementPresentationReference + from adcp.types.domains.core.preview_provider import PublisherDesignatedPreviewProvider, Route + from adcp.types.domains.core.preview_renderer_metadata import ( + PreviewRendererMetadata, + RenderingOrigin, + ) + from adcp.types.domains.core.price import Price + from adcp.types.domains.core.pricing_option import PricingOption + from adcp.types.domains.core.principal_changed_webhook import PrincipalChangedWebhook + from adcp.types.domains.core.principal_declarations import ( + AgentDeclarations, + AsyncAdcpVersion, + WebhookSigningAlgorithm, + ) + from adcp.types.domains.core.principal_declarations_state import ( + Axis, + Exclusion, + PrincipalDeclarationsState, + ) + from adcp.types.domains.core.principal_state import ( + DestinationRef, + PrincipalState, + RetiredDestination, + ) + from adcp.types.domains.core.product import ( + AudienceActivation, + ConversionTracking, + DeliveryMeasurement, + MaterialSubmission, + MetricOptimization, + Product, + ProductCard, + ProductCardDetailed, + PublisherProperty81, + PublisherProperty82, + PublisherProperty83, + PublisherProperty84, + PublisherProperty85, + PublisherProperty86, + PublisherProperty87, + Specification, + SupportedMetric, + SupportedTarget5, + SupportedViewDuration, + ) + from adcp.types.domains.core.product_allocation import ProductAllocation + from adcp.types.domains.core.product_allowed_action import ProductAllowedAction + from adcp.types.domains.core.product_audience_evidence_requirements import ( + AcceptedAttestationIssuers, + AcceptedAttestationIssuers1, + AcceptedAttestationIssuers2, + AcceptedAttestationIssuers3, + ProductAudienceEvidenceRequirements, + ) + from adcp.types.domains.core.product_card_reference_asset import ProductCardReferenceAsset + from adcp.types.domains.core.product_change_map import ProductChangeMap, ProductChangeMap1 + from adcp.types.domains.core.product_execution_requirement import ( + Connection, + ProductExecutionRequirement, + ProductExecutionRequirement1, + ProductExecutionRequirement2, + ProductExecutionRequirement3, + ) + from adcp.types.domains.core.product_filters import ( + AudienceActivationMethods, + AudienceActivationMethods1, + AudienceActivationMethods2, + AudienceActivationMethods3, + AudienceActivationMethods4, + AudienceActivationMethods5, + Keyword, + ProductFilters, + RequiredGeoTargetingItem, + SignalTargetingItem, + SignalTargetingItem1, + SignalTargetingItem2, + SignalTargetingItem3, + SignalTargetingItem4, + SignalTargetingItem5, + SignalTargetingItem6, + SignalTargetingItem7, + ) + from adcp.types.domains.core.product_format_declaration import ( + ProductFormatDeclaration, + ProductFormatDeclaration1, + ProductFormatDeclaration10, + ProductFormatDeclaration11, + ProductFormatDeclaration12, + ProductFormatDeclaration13, + ProductFormatDeclaration14, + ProductFormatDeclaration15, + ProductFormatDeclaration16, + ProductFormatDeclaration2, + ProductFormatDeclaration3, + ProductFormatDeclaration4, + ProductFormatDeclaration5, + ProductFormatDeclaration6, + ProductFormatDeclaration7, + ProductFormatDeclaration8, + ProductFormatDeclaration9, + ) + from adcp.types.domains.core.product_identity import ProductIdentity + from adcp.types.domains.core.product_offer_filters import ( + AvailabilityHorizon, + ProductOfferFilters, + RequiredPerformanceStandard, + ) + from adcp.types.domains.core.product_signal_targeting_option import ( + ActivationStatus, + AllowedTargetingMode, + ProductSignalTargetingOption, + ) + from adcp.types.domains.core.product_targeting_resolution import ProductTargetingResolution + from adcp.types.domains.core.property import Property + from adcp.types.domains.core.property_delivery_metrics import PropertyDeliveryMetrics + from adcp.types.domains.core.property_id import PropertyId + from adcp.types.domains.core.property_list_ref import PropertyListReference + from adcp.types.domains.core.property_ref import PropertyReference + from adcp.types.domains.core.property_tag import PropertyTag + from adcp.types.domains.core.proposal import Proposal + from adcp.types.domains.core.protocol_envelope import ProtocolEnvelope + from adcp.types.domains.core.provenance import VerifyAgent18 + from adcp.types.domains.core.publisher_property_selector import ( + PublisherPropertySelector, + PublisherPropertySelector1, + PublisherPropertySelector2, + PublisherPropertySelector3, + ) + from adcp.types.domains.core.push_notification_config import PushNotificationConfig + from adcp.types.domains.core.real_estate_item import ListingType, PropertyType, RealEstateItem + from adcp.types.domains.core.reference_renderer import ReferenceRenderer + from adcp.types.domains.core.registry_event import ( + AgentProfilePayload, + AuthorizationPayload, + AuthorizationType, + BadgeRole, + ChangedFields, + Classification, + CollectionIdentifier, + CollectionPayload, + CompliancePayload, + ComplianceStatus, + DelegationType, + GradingProfile, + Market, + Payload1, + Payload10, + Payload11, + Payload12, + Payload13, + Payload14, + Payload2, + Payload3, + Payload4, + Payload5, + Payload6, + Payload7, + Payload8, + Payload9, + PropertyPayload, + PropertySource, + PublisherAdagentsPayload, + RegistryEvent, + RegistryEvent1, + RegistryEvent10, + RegistryEvent11, + RegistryEvent12, + RegistryEvent13, + RegistryEvent14, + RegistryEvent15, + RegistryEvent16, + RegistryEvent17, + RegistryEvent18, + RegistryEvent19, + RegistryEvent2, + RegistryEvent20, + RegistryEvent3, + RegistryEvent4, + RegistryEvent5, + RegistryEvent6, + RegistryEvent7, + RegistryEvent8, + RegistryEvent9, + Storyboard, + StoryboardStatus, + StringArray, + Tracks, + ) + from adcp.types.domains.core.registry_feed_response import Freshness, RegistryFeedResponse + from adcp.types.domains.core.reporting_adjustment import AccountingPeriod, ReportingAdjustment + from adcp.types.domains.core.reporting_adjustment_receipt import ( + ReportingAdjustmentReceipt, + ReportingAdjustmentRejectionCode, + ) + from adcp.types.domains.core.reporting_canonical_content_digest import ( + ReportingCanonicalContentDigest, + ) + from adcp.types.domains.core.reporting_canonicalization_contract import ( + AdditionalItem, + EmptyReport, + GoldenVectors, + OrderingEncoding, + ReportingCanonicalizationContract, + ReportingPrimaryKey, + ) + from adcp.types.domains.core.reporting_capabilities import ReportingCapabilities + from adcp.types.domains.core.reporting_consumer_status import ( + ConsumerStatus, + FailureCode, + MismatchCode, + ReportingConsumerStatus, + ) + from adcp.types.domains.core.reporting_control_total import ( + ReportingControlTotal, + ReportingControlTotal1, + ReportingControlTotal2, + ) + from adcp.types.domains.core.reporting_coverage import ( + Limitation, + ReportingCoverage, + ReportingMediaBuyId, + ReportingPackageId, + ) + from adcp.types.domains.core.reporting_dataset_share_destination import ( + Recipient, + ReportingCloud, + ReportingDatasetShareDestination, + ReportingDatasetShareDestination1, + ReportingDatasetShareDestination2, + ) + from adcp.types.domains.core.reporting_delivery_capabilities import ( + OperationsContact, + ReportingDeliveryCapabilities, + ) + from adcp.types.domains.core.reporting_delivery_config import ( + AuthoritativeParty, + CoverageRequirement, + ReportingDeliveryConfiguration, + ) + from adcp.types.domains.core.reporting_delivery_config_state import ( + ReportingDeliveryConfigLifecycleState, + ReportingDeliveryConfigurationState, + ) + from adcp.types.domains.core.reporting_delivery_method import ( + ReportingDeliveryMethod, + ReportingDeliveryMethod1, + ReportingDeliveryMethod2, + ReportingDeliveryMethod3, + ReportingOrchestration, + ) + from adcp.types.domains.core.reporting_delivery_offering import ( + DestinationMode, + ProducerIdentity, + ReportingDeliveryOffering, + ReportingDeliveryPattern, + ReportingFeedPurpose, + ReportingProfile, + ) + from adcp.types.domains.core.reporting_delivery_offering_id import ReportingDeliveryOfferingId + from adcp.types.domains.core.reporting_delivery_ready_webhook import ( + Readiness, + ReportingDeliveryReadyWebhook, + ) + from adcp.types.domains.core.reporting_file_compression import ReportingFileCompression + from adcp.types.domains.core.reporting_file_entry import ReportingFileEntry + from adcp.types.domains.core.reporting_file_manifest import ReportingFileManifest + from adcp.types.domains.core.reporting_file_object_ref import ReportingFileObjectReference + from adcp.types.domains.core.reporting_ledger_changed_webhook import ( + ReportingLedgerChangedWebhook, + ) + from adcp.types.domains.core.reporting_materialization import ReportingMaterialization + from adcp.types.domains.core.reporting_native_version_ref import ReportingNativeVersionReference + from adcp.types.domains.core.reporting_obligation import ( + ProductionStatus, + ReconciliationStatus, + ReportingObligation, + ) + from adcp.types.domains.core.reporting_receipt import RejectionCode, ReportingReceipt + from adcp.types.domains.core.reporting_reconciliation_mode import ReportingReconciliationMode + from adcp.types.domains.core.reporting_reliability_statistics import ( + AdjustmentMagnitudeItem, + Basis, + LatencyPercentiles, + ReportingReliabilityMeasurementPeriod, + ReportingReliabilityStatistics, + ) + from adcp.types.domains.core.reporting_report_definition import ( + Aggregation, + Calendar, + ContractVersion, + FinalityPolicies, + FinalityPolicies1, + FinalityPolicies2, + ReportCalendarTimezoneBasis, + ReportingReportDefinition, + RestatementPolicy, + ) + from adcp.types.domains.core.reporting_resource import ( + Immutability, + ReportingReaderCompatibilityItem, + ReportingResource, + ) + from adcp.types.domains.core.reporting_revision import ( + DataThroughPrecision, + FinalityBasis, + ReportingRevision, + ) + from adcp.types.domains.core.reporting_schedule import ( + ReportingSchedule, + ReportingScheduleAlignment, + ) + from adcp.types.domains.core.reporting_schedule_offering import ( + PeriodAnchorPolicy, + PeriodTimezonePolicy, + ReportingScheduleOffering, + ) + from adcp.types.domains.core.reporting_status_changed_webhook import ( + IssueId, + ReportingStatusChangedWebhook, + ) + from adcp.types.domains.core.reporting_status_issue import ( + IssueState, + RecommendedAction, + ReportingStatusIssue, + ReportingStatusSeverity, + ResponsibleParty, + ) + from adcp.types.domains.core.reporting_verification import ( + NativeCommitEvidence, + ObservedThrough, + PhysicalChecksums, + PhysicalChecksums1, + ReportingVerification, + VerificationPath, + ) + from adcp.types.domains.core.reporting_verification_profile import ReportingVerificationProfile + from adcp.types.domains.core.reporting_verification_profile_set import ( + ReportingVerificationProfileSet, + ReportingVerificationProfileSetEnum, + ) + from adcp.types.domains.core.reporting_webhook import ReportingFrequency, ReportingWebhook + from adcp.types.domains.core.reporting_write_destination import ( + ReportingWriteDestination, + ReportingWriteDestination1, + ReportingWriteDestination2, + ) + from adcp.types.domains.core.representation_destination import RepresentationDestination + from adcp.types.domains.core.representation_rejection import RepresentationRejection + from adcp.types.domains.core.representation_selection import RepresentationSelection, ResolvedBy + from adcp.types.domains.core.requirements.asset_requirements import AssetRequirements + from adcp.types.domains.core.requirements.audio_asset_requirements import ( + AudioAssetRequirements, + SampleRate, + ) + from adcp.types.domains.core.requirements.catalog_field_binding import ( + AssetPoolBinding, + CatalogFieldBinding, + CatalogFieldBinding1, + PerItemBindings, + ScalarBinding, + ) + from adcp.types.domains.core.requirements.catalog_requirements import CatalogRequirements + from adcp.types.domains.core.requirements.css_asset_requirements import CssAssetRequirements + from adcp.types.domains.core.requirements.daast_asset_requirements import DaastAssetRequirements + from adcp.types.domains.core.requirements.html_asset_requirements import ( + HtmlAssetRequirements, + Sandbox, + ) + from adcp.types.domains.core.requirements.image_asset_requirements import ( + Bleed, + Bleed1, + ImageAssetRequirements, + PixelRatio, + ) + from adcp.types.domains.core.requirements.javascript_asset_requirements import ( + JavascriptAssetRequirements, + ModuleType, + ) + from adcp.types.domains.core.requirements.markdown_asset_requirements import ( + MarkdownAssetRequirements, + ) + from adcp.types.domains.core.requirements.offering_asset_constraint import ( + OfferingAssetConstraint, + ) + from adcp.types.domains.core.requirements.text_asset_requirements import TextAssetRequirements + from adcp.types.domains.core.requirements.url_asset_requirements import ( + Protocol, + UrlAssetRequirements, + ) + from adcp.types.domains.core.requirements.vast_asset_requirements import VastAssetRequirements + from adcp.types.domains.core.requirements.video_asset_requirements import ( + AudioCodec, + AudioSampleRate, + FrameRate, + VideoAssetRequirements, + ) + from adcp.types.domains.core.requirements.webhook_asset_requirements import ( + WebhookAssetRequirements, + ) + from adcp.types.domains.core.response import ProtocolResponse + from adcp.types.domains.core.response_payload_jws_envelope import ( + ResponsePayload, + ResponsePayloadJwsEnvelope, + ) + from adcp.types.domains.core.rights_attestation_evaluation import ( + Issuer5, + Issuer6, + Reference, + RightsAttestationEvaluation, + Subject10, + Subject19, + ) + from adcp.types.domains.core.rights_constraint import ( + ApprovalStatus, + ExcludedCountry, + GrantStatus, + Issuer8, + Issuer9, + Restriction, + RightsAgent, + RightsConstraint, + Subject20, + Subject29, + ) + from adcp.types.domains.core.seller_agent_ref import SellerAgentReference + from adcp.types.domains.core.signal_coverage_forecast import ( + BucketCompleteness, + BucketSemantics, + Point, + SignalCoverageForecast, + ) + from adcp.types.domains.core.signal_definition import AudienceScope, SignalDefinition, Tag + from adcp.types.domains.core.signal_definition_enrichment import SignalDefinitionEnrichment + from adcp.types.domains.core.signal_filters import SignalFilters + from adcp.types.domains.core.signal_id import SignalId8, SignalId9 + from adcp.types.domains.core.signal_listing import SignalListing + from adcp.types.domains.core.signal_modeling_disclosure import ( + Audience, + SignalModelingDisclosure, + ) + from adcp.types.domains.core.signal_pricing import ( + VendorPricing, + VendorPricing1, + VendorPricing2, + VendorPricing3, + VendorPricing4, + VendorPricing5, + ) + from adcp.types.domains.core.signal_pricing_option import SignalPricingOption + from adcp.types.domains.core.signal_ref import SignalRef, SignalRef1, SignalRef2, SignalRef3 + from adcp.types.domains.core.signal_selection_group_rule import SignalSelectionGroupRule + from adcp.types.domains.core.signal_targeting import ( + SignalTargeting, + SignalTargeting1, + SignalTargeting2, + SignalTargeting3, + ) + from adcp.types.domains.core.signal_targeting_expression import ( + SignalTargetingExpression, + SignalTargetingExpression1, + SignalTargetingExpression2, + SignalTargetingExpression3, + ) + from adcp.types.domains.core.signal_targeting_rules import ResolutionModel, SignalTargetingRules + from adcp.types.domains.core.sla_window import SlaWindow + from adcp.types.domains.core.special import Special + from adcp.types.domains.core.spot_reporting_capability import SpotReportingCapability + from adcp.types.domains.core.start_timing import StartTiming + from adcp.types.domains.core.store_item import StoreItem + from adcp.types.domains.core.talent import Talent + from adcp.types.domains.core.targeting import AgeRestriction, TargetingOverlay + from adcp.types.domains.core.targeting_input import TargetingOverlayInput + from adcp.types.domains.core.targeting_modification import ( + Path, + Selector, + TargetingModification, + TargetingModification1, + TargetingModification2, + ) + from adcp.types.domains.core.targeting_overlay_requirements import ( + BrowserRequirement, + BrowserRequirement1, + DaypartRequirement, + DaypartRequirement1, + KeywordRequirement, + KeywordRequirement1, + MetroRequirement, + MetroRequirement1, + Required, + TargetingOverlayRequirements, + ) + from adcp.types.domains.core.targeting_overlay_support import ( + BrowserSupport, + BrowserSupport1, + CountrySupport, + CountrySupport1, + DaypartSupport, + DaypartSupport1, + IanaTimezones, + KeywordSupport, + KeywordSupport1, + MetroSupport, + MetroSupport1, + PlaceCatalogSupport, + PlaceSupport, + Supported, + TargetingOverlaySupport, + ) + from adcp.types.domains.core.targeting_unknown_age_eligibility_constraint import ( + TargetingUnknownAgeEligibilityConstraint, + ) + from adcp.types.domains.core.targeting_verified_age_basis_constraint import ( + TargetingVerifiedAgeBasisConstraint, + ) + from adcp.types.domains.core.tasks_get_request import TasksGetRequest + from adcp.types.domains.core.tasks_get_response import ( + Details, + HistoryItem, + Progress, + TasksGetResponse, + ) + from adcp.types.domains.core.tasks_list_request import Filters, Sort, TasksListRequest + from adcp.types.domains.core.tasks_list_response import ( + DomainBreakdown, + QuerySummary, + SortApplied, + TasksListResponse, + ) + from adcp.types.domains.core.tracker_execution_contract import TrackerExecutionContract + from adcp.types.domains.core.tracker_execution_selector import ( + TrackerExecutionSelector, + TrackerExecutionSelector1, + TrackerExecutionSelector2, + TrackerExecutionSelector3, + ) + from adcp.types.domains.core.transformer import ( + BrandAgent, + InputFormat, + InputFormat1, + InputFormat10, + InputFormat11, + InputFormat12, + InputFormat13, + InputFormat14, + InputFormat15, + InputFormat16, + InputFormat17, + InputFormat18, + InputFormat19, + InputFormat2, + InputFormat20, + InputFormat21, + InputFormat22, + InputFormat23, + InputFormat24, + InputFormat25, + InputFormat26, + InputFormat27, + InputFormat28, + InputFormat29, + InputFormat3, + InputFormat30, + InputFormat31, + InputFormat32, + InputFormat33, + InputFormat4, + InputFormat5, + InputFormat6, + InputFormat7, + InputFormat8, + InputFormat9, + Multiplicity, + OutputCapabilityId, + Transformer, + VariantDimension, + VoiceSynthesisRefItem, + ) + from adcp.types.domains.core.transformer_param import Option, TransformerParam, ValueSource + from adcp.types.domains.core.truncation_sentinel import FieldTruncation, TruncationSentinel + from adcp.types.domains.core.user_match import UserMatch + from adcp.types.domains.core.vast_media_file_requirements import ( + MimeType, + VastMediafileRequirements, + ) + from adcp.types.domains.core.vast_tracker_constraints import ( + VastEvent, + VastOffset, + VastTarget, + VastTrackerConstraints, + VastVersions, + ) + from adcp.types.domains.core.vehicle_item import ( + BodyStyle, + Condition, + FuelType, + Mileage, + Transmission, + VehicleItem, + ) + from adcp.types.domains.core.vendor_metric_id import VendorMetricId + from adcp.types.domains.core.vendor_metric_optimization import VendorMetricOptimization + from adcp.types.domains.core.vendor_metric_optimization_supported_metric import ( + VendorMetricOptimizationSupportedMetric, + ) + from adcp.types.domains.core.vendor_metric_value import VendorMetricValue + from adcp.types.domains.core.vendor_pricing_option import ( + AppliesToOutputCapabilityId, + VendorPricingOption, + VendorPricingOption1, + VendorPricingOption10, + VendorPricingOption11, + VendorPricingOption2, + VendorPricingOption3, + VendorPricingOption4, + VendorPricingOption5, + VendorPricingOption6, + VendorPricingOption7, + VendorPricingOption8, + VendorPricingOption9, + ) + from adcp.types.domains.core.verification_token_claims import ( + AgenticadvertisingOrgVerificationTokenClaims, + VerificationTokenGradingProfile, + VerificationTokenMode, + ) + from adcp.types.domains.core.version_envelope import AdcpVersionEnvelope + from adcp.types.domains.core.warning import Warning + from adcp.types.domains.core.warning_resource import WarningAffectedResource + from adcp.types.domains.core.webhook_activity_record import WebhookActivityRecord + from adcp.types.domains.core.webhook_challenge import WebhookChallenge + from adcp.types.domains.core.webhook_challenge_response import ( + WebhookChallengeResponse, + WebhookChallengeResponseBranches, + WebhookChallengeResponseChallenge, + WebhookChallengeResponseToken, + ) + from adcp.types.domains.core.wholesale_feed_event import ( + AffectedEntityType, + AppliesTo, + AppliesTo1, + AppliesTo2, + Payload17, + Payload18, + Payload20, + Payload21, + Payload22, + Payload23, + Payload24, + Payload25, + RemovalReason, + Signal, + WholesaleFeedEvent, + WholesaleFeedEvent1, + WholesaleFeedEvent2, + WholesaleFeedEvent3, + WholesaleFeedEvent4, + WholesaleFeedEvent5, + WholesaleFeedEvent6, + WholesaleFeedEvent7, + WholesaleFeedEvent8, + WholesaleFeedEvent9, + ) + from adcp.types.domains.core.wholesale_feed_webhook import ( + CacheScope, + NotificationType, + WholesaleFeedWebhook, + ) + from adcp.types.domains.core.x_entity_types import XEntityTypes + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "core.acceptance_policy_profile_ids": ( + "AcceptancePolicyProfileId", + "AcceptancePolicyProfileIds", + ), + "core.account": ( + "Account", + "Compression", + "CreditLimit", + "GovernanceAgent", + "ReportingBucket", + ), + "core.account_authorization": ( + "AccountAuthorization", + "AllowedTask", + "ScopeName", + ), + "core.account_change": ( + "AccountChange", + "Actor", + "Repair", + "UnavailableReason", + ), + "core.account_change_recorded_webhook": ("AccountChangeRecordedWebhook",), + "core.account_identity_change": ( + "AccountIdentityChange", + "AccountIdentityChange1", + "AccountIdentityChange2", + ), + "core.account_identity_change_preview": ( + "AccountIdentityChangePreview", + "AccountIdentityChangePreview1", + "AccountIdentityChangePreview2", + "AccountIdentityChangePreview3", + "BlockedImpacts", + "Blocker", + "Effect1", + "Impact", + "NonblockingImpact", + "NonblockingImpacts", + ), + "core.account_ref": ( + "AccountReference", + "AccountReference1", + "AccountReference2", + ), + "core.account_status_changed_webhook": ("AccountStatusChangedWebhook",), + "core.account_timezone_capability": ( + "AccountSelection", + "AccountTimezoneCapability", + "SupportedTimezone", + ), + "core.account_with_authorization": ("AccountWithAuthorization",), + "core.activation_key": ( + "ActivationKey", + "ActivationKey1", + "ActivationKey2", + ), + "core.ad_inventory_config": ("AdInventoryConfiguration",), + "core.agent_encryption_key": ("AgentEncryptionKey",), + "core.agent_notification_config": ("AgentNotificationConfig",), + "core.agent_notification_config_state": ("AgentNotificationConfigState",), + "core.agent_reporting_destination": ( + "AcceptedFormat", + "AgentReportingDestination", + "AgentReportingDestination1", + "AgentReportingDestination2", + "AgentReportingDestination3", + ), + "core.agent_reporting_destination_state": ( + "AgentReportingDestinationState", + "PriorDestinationRef", + ), + "core.agent_signing_key": ("AgentSigningKey",), + "core.agent_webhook_challenge": ("AgentWebhookChallenge",), + "core.app_item": ( + "AppItem", + "Platform", + ), + "core.applicable_package_id": ("ApplicablePackageId",), + "core.asset_group_vocabulary": ("AdcpAssetGroupVocabularyRegistry",), + "core.assets.asset_union": ( + "AssetVariant", + "AudioChannelLayout", + "C2paWatermarkAction", + "CatalogType", + "ContentIdType", + "DaastAsset1", + "DaastAsset2", + "DaastTrackingEvent", + "DaastVersion", + "DigitalSourceType", + "DisclosurePersistence", + "DisclosurePosition", + "DisplayTagAsset1", + "DisplayTagAsset2", + "DisplayTagAsset3", + "EmbeddedProvenanceMethod", + "Ext", + "FeedFormat", + "FieldModel", + "FrameRateType", + "GopType", + "HttpMethod", + "JavascriptModuleType", + "Jurisdiction2", + "Location1", + "Location2", + "Location4", + "Location5", + "Location6", + "Location8", + "Location9", + "MacroBearingUrl1", + "MacroBearingUrl2", + "MacroDeclaration1", + "MacroDeclaration10", + "MacroDeclaration2", + "MacroDeclaration3", + "MacroDeclaration4", + "MacroDeclaration5", + "MacroDeclaration6", + "MacroDeclaration7", + "MacroDeclaration8", + "MacroDeclaration9", + "MacroDeclarationModel", + "MacroDialect", + "MacroMappingStatus", + "MacroProcessingOperation", + "MacroResolver", + "MacroValueContext", + "MarkdownFlavor", + "MoovAtomPosition", + "PixelTrackingEvent", + "PlatformExtensionRef", + "PublishedPostAsset1", + "PublishedPostAsset2", + "ReferenceAuthorization1", + "Role2", + "ScanType", + "Target1", + "UniversalMacro", + "UpdateFrequency", + "UrlAssetType", + "VastAsset1", + "VastAsset2", + "VastTrackingEvent", + "VastVersion", + "VerifyAgent1", + "WatermarkMediaType", + "WebhookResponseType", + "WebhookSecurityMethod", + ), + "core.assets.audio_asset": ("BitDepth",), + "core.assets.daast_asset": ( + "DaastAsset3", + "DaastAsset4", + "Location13", + "MacroDeclaration12", + ), + "core.assets.display_tag_asset": ( + "DisplayTagAsset4", + "DisplayTagAsset5", + "DisplayTagAsset6", + "Location17", + "Location18", + "MacroDeclaration15", + "MacroDeclaration16", + ), + "core.assets.vast_asset": ( + "Location26", + "MacroDeclaration20", + "VastAsset3", + "VastAsset4", + ), + "core.async_response_data": ("AdcpAsyncResponseData",), + "core.async_response_refs.creative.get_creative_features_async_response_submitted": ( + "GetCreativeFeaturesSubmitted", + ), + "core.async_response_refs.creative.sync_creatives_async_response_input_required": ( + "SyncCreativesInputRequired", + ), + "core.async_response_refs.creative.sync_creatives_async_response_submitted": ( + "SyncCreativesSubmitted", + ), + "core.async_response_refs.creative.sync_creatives_async_response_working": ( + "SyncCreativesWorking", + ), + "core.async_response_refs.media_buy.accept_proposal_async_response_input_required": ( + "AcceptProposalInputRequired", + ), + "core.async_response_refs.media_buy.accept_proposal_async_response_submitted": ( + "AcceptProposalSubmitted", + ), + "core.async_response_refs.media_buy.accept_proposal_async_response_working": ( + "AcceptProposalWorking", + ), + "core.async_response_refs.media_buy.build_creative_async_response_input_required": ( + "BuildCreativeInputRequired", + ), + "core.async_response_refs.media_buy.build_creative_async_response_submitted": ( + "BuildCreativeSubmitted", + ), + "core.async_response_refs.media_buy.build_creative_async_response_working": ( + "BuildCreativeWorking", + ), + "core.async_response_refs.media_buy.buy_products_async_response_input_required": ( + "BuyProductsInputRequired", + ), + "core.async_response_refs.media_buy.buy_products_async_response_submitted": ( + "BuyProductsSubmitted", + ), + "core.async_response_refs.media_buy.buy_products_async_response_working": ( + "BuyProductsWorking", + ), + "core.async_response_refs.media_buy.control_media_buy_async_response_input_required": ( + "ControlMediaBuyInputRequired", + ), + "core.async_response_refs.media_buy.control_media_buy_async_response_submitted": ( + "ControlMediaBuySubmitted", + ), + "core.async_response_refs.media_buy.control_media_buy_async_response_working": ( + "ControlMediaBuyWorking", + ), + "core.async_response_refs.media_buy.create_media_buy_async_response_input_required": ( + "CreateMediaBuyInputRequired", + ), + "core.async_response_refs.media_buy.create_media_buy_async_response_submitted": ( + "CreateMediaBuySubmitted", + ), + "core.async_response_refs.media_buy.create_media_buy_async_response_working": ( + "CreateMediaBuyWorking", + ), + "core.async_response_refs.media_buy.decline_proposals_async_response_input_required": ( + "DeclineProposalsInputRequired", + ), + "core.async_response_refs.media_buy.decline_proposals_async_response_submitted": ( + "DeclineProposalsSubmitted", + ), + "core.async_response_refs.media_buy.decline_proposals_async_response_working": ( + "DeclineProposalsWorking", + ), + "core.async_response_refs.media_buy.get_products_async_response_input_required": ( + "GetProductsInputRequired", + ), + "core.async_response_refs.media_buy.get_products_async_response_submitted": ( + "GetProductsSubmitted", + ), + "core.async_response_refs.media_buy.get_products_async_response_working": ( + "GetProductsWorking", + ), + "core.async_response_refs.media_buy.refine_proposals_async_response_input_required": ( + "RefineProposalsInputRequired", + ), + "core.async_response_refs.media_buy.refine_proposals_async_response_submitted": ( + "RefineProposalsSubmitted", + ), + "core.async_response_refs.media_buy.refine_proposals_async_response_working": ( + "RefineProposalsWorking", + ), + "core.async_response_refs.media_buy.request_proposals_async_response_input_required": ( + "RequestProposalsInputRequired", + ), + "core.async_response_refs.media_buy.request_proposals_async_response_submitted": ( + "RequestProposalsSubmitted", + ), + "core.async_response_refs.media_buy.request_proposals_async_response_working": ( + "RequestProposalsWorking", + ), + "core.async_response_refs.media_buy.sync_catalogs_async_response_input_required": ( + "SyncCatalogsInputRequired", + ), + "core.async_response_refs.media_buy.sync_catalogs_async_response_submitted": ( + "SyncCatalogsSubmitted", + ), + "core.async_response_refs.media_buy.sync_catalogs_async_response_working": ( + "SyncCatalogsWorking", + ), + "core.async_response_refs.media_buy.update_media_buy_async_response_input_required": ( + "UpdateMediaBuyInputRequired", + ), + "core.async_response_refs.media_buy.update_media_buy_async_response_submitted": ( + "UpdateMediaBuySubmitted", + ), + "core.async_response_refs.media_buy.update_media_buy_async_response_working": ( + "UpdateMediaBuyWorking", + ), + "core.async_response_refs.signals.get_signals_async_response_submitted": ( + "GetSignalsSubmitted", + ), + "core.async_response_refs.signals.get_signals_async_response_working": ("GetSignalsWorking",), + "core.attestation_capabilities": ( + "AcceptedIssuer", + "AttestationCapabilities", + "CredentialOrigin", + "Resolver", + "SupportedDeliveryMethod", + ), + "core.attestation_evaluation": ("Outcome",), + "core.attestation_issuer": ( + "AttestationIssuer", + "AttestationIssuer1", + "AttestationIssuer2", + "AttestationIssuer3", + ), + "core.attestation_reference": ( + "AttestationReference", + "EmbeddedCredential", + "Locator", + "Locator1", + "ValidityHint", + ), + "core.attestation_subject": ( + "AttestationSubject", + "AttestationSubject1", + "AttestationSubject2", + "AttestationSubject3", + ), + "core.attribution_window": ("AttributionWindow",), + "core.audience_activation_method": ( + "AudienceActivationMethod", + "AudienceActivationMethod1", + "AudienceActivationMethod2", + "AudienceActivationMethod3", + "AudienceActivationMethod4", + "AudienceActivationMethod5", + "AudienceActivationMethod6", + "Cloud", + "ConsumerIdentity", + ), + "core.audience_characteristic": ( + "AudienceCharacteristic", + "AudienceCharacteristicBranches", + "AudienceCharacteristicRange", + "AudienceCharacteristicValue", + ), + "core.audience_evidence": ( + "AudienceEvidence", + "Subject11", + "Subject12", + "Subject13", + "Subject14", + "Subject15", + "Subject16", + "Subject17", + "Subject2", + "Subject21", + "Subject22", + "Subject23", + "Subject24", + "Subject25", + "Subject26", + "Subject27", + "Subject3", + "Subject31", + "Subject32", + "Subject33", + "Subject34", + "Subject35", + "Subject36", + "Subject37", + ), + "core.audience_evidence_pin": ("AudienceEvidencePin",), + "core.audience_evidence_requirements": ("AudienceEvidenceRequirements",), + "core.audience_evidence_selection": ("AudienceEvidenceSelection",), + "core.audience_member": ("AudienceMember",), + "core.audience_selector": ( + "AudienceSelector", + "AudienceSelector1", + "AudienceSelector2", + "AudienceSelector3", + "AudienceSelector4", + ), + "core.audience_source": ( + "AudienceSource", + "AudienceSource1", + "AudienceSource2", + ), + "core.authorized_agent_base": ("AuthorizedAgentBaseFields",), + "core.bidding_policy": ( + "BiddingPolicy", + "CostPer", + "Roas", + "Strength", + "Strength1", + ), + "core.bidding_policy_capability": ( + "BiddingPolicyCapability", + "CostPerStrength", + "MaxBidWithCostPer", + "MaxBidWithRoas", + "PolicyProfile", + "RoasStrength", + "ScopeCapability", + ), + "core.brand_id": ("BrandId",), + "core.brand_key": ("BrandKey",), + "core.brand_ref": ( + "BrandKitOverride", + "BrandReference", + "Colors", + "DataSubjectContestation", + ), + "core.brand_response_authorization_result": ( + "BrandResponseAuthorizationResult", + "BrandResponseAuthorizationResult1", + "BrandResponseAuthorizationResult2", + "Trust", + ), + "core.budget_allocation": ( + "BudgetAllocation", + "BudgetAllocation1", + "BudgetAllocation2", + "OptimizationGoal1", + "OptimizationGoal2", + "OptimizationGoal3", + "OptimizationGoal4", + "OptimizationGoal5", + "OptimizationGoal6", + "OptimizationGoal7", + "Target3", + "Target4", + "Target5", + "Target6", + "Target7", + "Target8", + ), + "core.business_entity": ( + "Bank", + "BusinessEntity", + "Contact", + ), + "core.cancellation_policy": ( + "CancellationFee", + "CancellationPolicy", + ), + "core.canonical_account_ref": ( + "CanonicalAccountReference", + "CanonicalAccountReference1", + "CanonicalAccountReference2", + ), + "core.canonical_audience_evidence": ( + "AttestationDigest", + "CanonicalAudienceEvidence", + ), + "core.canonical_audience_evidence_selection": ( + "CanonicalAudienceEvidenceSelection", + "VerifiedAttestationDigest", + ), + "core.canonical_budget_allocation": ( + "CanonicalBudgetAllocation", + "CanonicalBudgetAllocation1", + "CanonicalBudgetAllocation2", + ), + "core.canonical_delivery_forecast": ("CanonicalDeliveryForecast",), + "core.canonical_forecast_point": ("CanonicalForecastPoint",), + "core.canonical_forecast_vendor_metric_value": ("CanonicalForecastVendorMetricValue",), + "core.canonical_format_kind": ("CanonicalFormatKind",), + "core.canonical_format_option": ( + "CanonicalFormatOption", + "FormatKind", + ), + "core.canonical_measurement_terms": ("CanonicalMeasurementTerms",), + "core.canonical_media_buy_action": ( + "Action3", + "Action4", + "CanonicalMediaBuyAction", + "CanonicalMediaBuyAction1", + "CanonicalMediaBuyAction2", + "CanonicalMediaBuyAction3", + ), + "core.canonical_media_buy_action_fields": ("CanonicalMediaBuyActionFields",), + "core.canonical_media_buy_features": ("CanonicalMediaBuyFeatures",), + "core.canonical_metric_qualifier": ("CanonicalMetricQualifier",), + "core.canonical_optimization_goal": ( + "CanonicalOptimizationGoal", + "CanonicalOptimizationGoal1", + "CanonicalOptimizationGoal2", + "CanonicalOptimizationGoal3", + "Target10", + "Target11", + ), + "core.canonical_performance_standard": ("CanonicalPerformanceStandard",), + "core.canonical_placement": ( + "CanonicalDoohPlacementAttributes", + "CanonicalDoohScreenResolution", + "CanonicalProductPlacement", + "CanonicalProductPlacement1", + "CanonicalProductPlacement2", + ), + "core.canonical_pricing_option": ( + "CanonicalPricingOption", + "PricingModel", + ), + "core.canonical_product": ( + "CanonicalProduct", + "PublisherProperty1", + "PublisherProperty2", + "PublisherProperty3", + "PublisherProperty4", + "PublisherProperty5", + "PublisherProperty6", + "PublisherProperty7", + ), + "core.canonical_product_action": ("CanonicalProductAction",), + "core.canonical_projection_ref": ( + "AssetSource", + "CanonicalProjectionReference", + ), + "core.canonical_projection_slot_override": ("CanonicalProjectionSlotOverride",), + "core.canonical_proposal": ( + "CanonicalProposal", + "ProposalKind", + ), + "core.canonical_reporting_capabilities": ("CanonicalReportingCapabilities",), + "core.canonical_reporting_commitment": ( + "CanonicalReportingCommitment", + "CanonicalReportingCommitment1", + "CanonicalReportingCommitment2", + ), + "core.canvas_constraint": ( + "CanvasConstraint", + "Constraint", + ), + "core.capabilities_changed_webhook": ("CapabilitiesChangedWebhook",), + "core.catalog_item_availability_error": ("CatalogItemAvailabilityError",), + "core.catalog_item_availability_ref": ("CatalogItemAvailabilityReference",), + "core.catalog_item_availability_state": ("CatalogItemAvailabilityState",), + "core.catalog_item_availability_update": ("CatalogItemAvailabilityUpdate",), + "core.catalog_item_availability_update_result": ("CatalogItemAvailabilityUpdateResult",), + "core.catalog_item_delivery_metrics": ("CatalogItemDeliveryMetrics",), + "core.catalog_item_reference_not_found_error": ("CatalogItemReferenceNotFoundError",), + "core.catalog_selection": ("CatalogSelection",), + "core.catchment": ( + "Catchment", + "CatchmentBranches", + "CatchmentGeometry", + "CatchmentRadius", + "CatchmentTransportModeTravelTime", + ), + "core.collection": ( + "Collection", + "RelatedCollection", + ), + "core.collection_delivery_metrics": ("CollectionDeliveryMetrics",), + "core.collection_distribution": ("CollectionDistribution",), + "core.collection_list_ref": ("CollectionListReference",), + "core.collection_property_delivery_metrics": ("CollectionPropertyDeliveryMetrics",), + "core.collection_ref": ("CollectionReference",), + "core.collection_selection": ( + "CollectionSelection", + "CollectionSelection1", + "CollectionSelection2", + ), + "core.collection_selector": ("CollectionSelector",), + "core.committed_metric": ( + "CommittedMetric", + "CommittedMetric1", + "CommittedMetric2", + "Qualifier1", + "QualifierModel", + ), + "core.compact_task_input_required": ("CompactTaskInputRequired",), + "core.compact_task_submitted": ("CompactTaskSubmitted",), + "core.compact_task_working": ("CompactTaskWorking",), + "core.content_rating": ("ContentRating",), + "core.context": ("ContextObject",), + "core.creative_approval_scope": ("ScopedCreativeApproval",), + "core.creative_asset": ( + "CreativeAsset", + "CreativeAssetBranches", + "CreativeAssetV1CreativeNamedFormatReference", + "CreativeAssetV2CreativeCanonicalFormatKind", + "Input", + ), + "core.creative_assets": ( + "CreativeAssets", + "CreativeAssets1", + ), + "core.creative_assignment": ( + "CreativeAssignment", + "RotationMode", + ), + "core.creative_consumption": ("CreativeConsumption",), + "core.creative_delivery_metrics": ("CreativeDeliveryMetrics",), + "core.creative_filters": ("CreativeFilters",), + "core.creative_item": ( + "CreativeItem", + "CreativeItem1", + "CreativeItem2", + ), + "core.creative_locale_policy": ("CreativeLocalePolicy",), + "core.creative_localization": ( + "CreativeLocalization", + "TargetVariant", + ), + "core.creative_localization_readback": ( + "CreativeLocalizationReadback", + "ResolvedAssets", + "ResolvedAssets1", + "Variants", + "Variants1", + "Variants2", + ), + "core.creative_manifest": ( + "CreativeManifest", + "CreativeManifestBranches", + "CreativeManifestCanonicalFormatManifest", + "CreativeManifestNamedFormatManifest", + ), + "core.creative_operation_format_declaration": ("CreativeOperationFormatDeclaration",), + "core.creative_policy": ( + "CreativePolicy", + "ProvenanceRequirements", + ), + "core.creative_representation": ("CreativeRepresentation",), + "core.creative_representation_set": ("CreativeRepresentationSet",), + "core.creative_revision_id": ("CreativeRevisionId",), + "core.creative_variable": ( + "CreativeVariable", + "VariableType", + ), + "core.creative_variant": ( + "Artifact", + "CreativeVariant", + "GenerationContext", + ), + "core.daast_tracker_constraints": ( + "DaastEvent", + "DaastOffset", + "DaastTarget", + "DaastTrackerConstraints", + "DaastVersions", + ), + "core.data_provider_signal_selector": ( + "DataProviderSignalSelector", + "DataProviderSignalSelector1", + "DataProviderSignalSelector2", + "DataProviderSignalSelector3", + "SignalTag", + ), + "core.date_range": ("DateRange",), + "core.datetime_range": ("DatetimeRange",), + "core.daypart_target": ("DaypartTarget",), + "core.deadline_policy": ( + "DeadlinePolicy", + "MaterialStage", + ), + "core.delivery_breakdown_controls": ("DeliveryBreakdownControls",), + "core.delivery_forecast": ("DeliveryForecast",), + "core.delivery_metric_aggregate": ( + "DeliveryMetricAggregate", + "DeliveryMetricAggregate1", + "DeliveryMetricAggregate2", + "Field0", + "Qualifier3", + ), + "core.delivery_metrics": ( + "ByActionSourceItem", + "ByEventTypeItem", + "DeliveryMetrics", + "DoohMetrics", + "DoohMetrics1", + "EstimationBasis", + "OohMetrics", + "Panel", + "Posting", + "QuartileData", + "ReachWindow", + "TimeBasedView", + "VenueBreakdownItem", + "Viewability1", + "ViewedSecondsHistogramItem", + "ViewedSecondsPercentiles", + ), + "core.delivery_provider": ("DeliveryProvider",), + "core.delivery_recipient": ("DeliveryRecipient",), + "core.demographic_age_range": ("DemographicAgeRange",), + "core.demographic_predicate": ("DemographicPredicate",), + "core.demographic_reporting_capability": ( + "DemographicReportingCapability", + "ReportingMode", + ), + "core.demographic_targeting_capability": ( + "DemographicTargetingCapability", + "ExecutionMode", + "UnknownHandling", + ), + "core.demographic_targeting_intent": ("DemographicTargetingIntent",), + "core.demographic_targeting_resolution": ( + "DemographicTargetingResolution", + "Execution", + "Execution1", + "Execution2", + "IntervalId", + ), + "core.deployment": ( + "Deployment", + "Deployment1", + "Deployment2", + ), + "core.destination": ( + "Destination1", + "Destination2", + ), + "core.destination_item": ( + "DestinationItem", + "DestinationType", + ), + "core.diagnostic_issue": ( + "DiagnosticIssue", + "Severity", + ), + "core.downstream_connection_requirement": ( + "ConnectionType", + "DownstreamConnectionRequirement", + "ResourceRef", + ), + "core.duration": ("Duration",), + "core.education_item": ( + "DegreeType", + "EducationItem", + "Level", + "Modality", + ), + "core.error": ( + "BuyerReason", + "DiscriminatorItem", + "Issue", + "Recovery", + ), + "core.evaluator_spec": ( + "EvalBudget", + "EvaluatorSpec", + "EvaluatorSpec1", + "EvaluatorSpec2", + "EvaluatorSpec3", + "Exemplars", + "FeatureAgent", + "RankByItem", + "RankByItem1", + "RankByItem2", + ), + "core.event": ("Event",), + "core.event_custom_data": ( + "Content", + "EventCustomData", + ), + "core.event_source_health": ( + "Detail", + "EventSourceHealth", + ), + "core.event_surface": ( + "Category", + "EventSurface", + ), + "core.experimental_feature_id": ("ExperimentalFeatureId",), + "core.ext": ("ExtensionObject",), + "core.feature_requirement": ( + "FeatureRequirement", + "IfNotCovered", + ), + "core.flight_item": ("FlightItem",), + "core.forecast_dimension_audience": ("AudienceForecastDimension",), + "core.forecast_dimension_device_platform": ("DevicePlatformForecastDimension",), + "core.forecast_dimension_device_type": ("DeviceTypeForecastDimension",), + "core.forecast_dimension_geo": ("GeoForecastDimension",), + "core.forecast_dimension_placement": ("PlacementForecastDimension",), + "core.forecast_dimension_signal": ( + "Presence", + "SignalForecastDimension", + ), + "core.forecast_dimension_time": ("TimeForecastDimension",), + "core.forecast_point": ( + "ForecastPoint", + "ViewableRate", + ), + "core.forecast_point_dimensions": ("ForecastPointDimensions",), + "core.forecast_range": ("ForecastRange",), + "core.forecast_rate_range": ("ForecastRateRange",), + "core.forecast_vendor_metric_value": ("ForecastVendorMetricValue",), + "core.format": ( + "Assets10", + "Assets11", + "Assets12", + "Assets13", + "Assets14", + "Assets15", + "Assets16", + "Assets17", + "Assets18", + "Assets19", + "Assets20", + "Assets21", + "Assets22", + "Assets23", + "Assets24", + "Assets25", + "Assets26", + "Assets27", + "Assets28", + "Assets29", + "Assets30", + "Assets31", + "Assets32", + "Assets33", + "Assets34", + "Assets35", + "Assets36", + "Assets37", + "Assets38", + "Assets39", + "Assets40", + "Assets41", + "Assets42", + "Assets43", + "Assets44", + "Assets45", + "Assets46", + "Assets47", + "Assets48", + "Assets49", + "Assets50", + "Assets51", + "Assets9", + "Assets94", + "BaseGroupAsset", + "BaseIndividualAsset", + "CanonicalParameters", + "CanonicalParameters1", + "CanonicalParameters10", + "CanonicalParameters11", + "CanonicalParameters12", + "CanonicalParameters13", + "CanonicalParameters14", + "CanonicalParameters15", + "CanonicalParameters16", + "CanonicalParameters17", + "CanonicalParameters18", + "CanonicalParameters19", + "CanonicalParameters2", + "CanonicalParameters20", + "CanonicalParameters21", + "CanonicalParameters22", + "CanonicalParameters23", + "CanonicalParameters24", + "CanonicalParameters25", + "CanonicalParameters26", + "CanonicalParameters27", + "CanonicalParameters28", + "CanonicalParameters29", + "CanonicalParameters3", + "CanonicalParameters30", + "CanonicalParameters31", + "CanonicalParameters32", + "CanonicalParameters33", + "CanonicalParameters4", + "CanonicalParameters5", + "CanonicalParameters6", + "CanonicalParameters7", + "CanonicalParameters8", + "CanonicalParameters9", + "Dimensions", + "Dimensions1", + "DisclosureCapability", + "FormatAssetUnion", + "FormatCard", + "FormatCardDetailed", + "GroupFormatAssetUnion", + "Renders", + "Renders1", + "Responsive", + "UnknownFormatAsset", + "UnknownGroupAsset", + ), + "core.format_id": ("FormatReferenceStructuredObject",), + "core.format_option_ref": ( + "FormatOptionReference", + "FormatOptionReference1", + "FormatOptionReference2", + ), + "core.format_shape_vocabulary": ("AdcpFormatShapeVocabularyRegistry",), + "core.frequency_cap": ("FrequencyCap",), + "core.frequency_cap_constraints": ("FrequencyCapConstraints",), + "core.frequency_cap_duration_unit": ("FrequencyCapDurationUnit",), + "core.frequency_cap_impression_constraints": ( + "AllowedValue", + "FrequencyCapImpressionConstraints", + "FrequencyCapImpressionConstraintsAllowedValues", + "FrequencyCapImpressionConstraintsBranches", + "FrequencyCapImpressionConstraintsMaximumMinimum", + ), + "core.frequency_cap_interval_constraints": ( + "AllowedInterval", + "FrequencyCapIntervalConstraints", + "FrequencyCapIntervalConstraintsAllowedIntervals", + "FrequencyCapIntervalConstraintsBranches", + "FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval", + ), + "core.frequency_cap_requirements": ("FrequencyCapRequirements",), + "core.generation_credential": ("GenerationCredential",), + "core.geo_breakdown_support": ("GeographicBreakdownSupport",), + "core.geo_delivery_metrics": ("GeoDeliveryMetrics",), + "core.geo_metro": ("GeoMetro",), + "core.geo_place_area": ("GeographicPlaceArea",), + "core.geo_place_catalog_capability": ( + "GeographicPlaceCatalogCapability", + "SupportedVersion", + ), + "core.geo_place_catalog_entry": ( + "GeographicPlaceCatalogEntry", + "ParentLabel", + "ReplacedByValue", + ), + "core.geo_place_requirement": ( + "CatalogRequirement", + "GeographicPlaceRequirement", + ), + "core.geo_place_resolver": ( + "Auth", + "GeographicPlaceResolver", + ), + "core.geo_place_support": ("GeographicPlaceSystemSupport",), + "core.geo_place_system": ( + "GeographicPlaceIdentifierSystem", + "GeographicPlaceIdentifierSystem1", + "GeographicPlaceIdentifierSystem2", + ), + "core.geo_place_type": ( + "GeographicPlaceType", + "GeographicPlaceType1", + "GeographicPlaceType2", + ), + "core.geo_region_requirement": ( + "Countries1", + "GeographicRegionRequirement", + ), + "core.geo_region_support": ( + "Countries3", + "GeographicRegionSupport", + ), + "core.get_geo_place_resolution_request": ( + "GetGeographicPlaceResolutionRequest", + "GetGeographicPlaceResolutionRequestBranches", + "GetGeographicPlaceResolutionRequestQ", + "GetGeographicPlaceResolutionRequestValue", + ), + "core.get_geo_place_resolution_response": ("GetGeographicPlaceResolutionResponse",), + "core.hotel_item": ("HotelItem",), + "core.iana_timezone": ("IanaTimezoneIdentifier",), + "core.impairment": ( + "Impairment", + "Transition", + ), + "core.indicator": ("Indicator",), + "core.indicator_bearing": ("IndicatorBearingResourceState",), + "core.indicator_scope": ("IndicatorScope",), + "core.indicators_changed_webhook": ( + "IndicatorsChangedWebhook", + "RelationshipKind", + ), + "core.industry_identifier": ("IndustryIdentifier",), + "core.insertion_order": ( + "InsertionOrder", + "PaymentTerms", + "Terms", + "TotalBudget", + ), + "core.installment": ( + "DerivativeOf", + "Installment", + ), + "core.installment_deadlines": ("InstallmentDeadlines",), + "core.installment_delivery_metrics": ("InstallmentDeliveryMetrics",), + "core.installment_property_delivery_metrics": ("InstallmentPropertyDeliveryMetrics",), + "core.installment_ref": ("InstallmentReference",), + "core.inventory_list_application": ( + "InventoryListApplication", + "InventoryListApplication1", + "InventoryListApplication2", + "Summary", + "Summary2", + ), + "core.job_item": ( + "EmploymentType", + "ExperienceLevel", + "JobItem", + "Salary", + ), + "core.keyword_delivery_metrics": ("KeywordDeliveryMetrics",), + "core.limited_series": ("LimitedSeries",), + "core.locale_tag": ("LanguageTag",), + "core.localized_creative_asset": ( + "LocalizedCreativeAsset", + "LocalizedCreativeAsset1", + "LocalizedCreativeAsset10", + "LocalizedCreativeAsset11", + "LocalizedCreativeAsset12", + "LocalizedCreativeAsset13", + "LocalizedCreativeAsset14", + "LocalizedCreativeAsset15", + "LocalizedCreativeAsset16", + "LocalizedCreativeAsset17", + "LocalizedCreativeAsset18", + "LocalizedCreativeAsset19", + "LocalizedCreativeAsset2", + "LocalizedCreativeAsset20", + "LocalizedCreativeAsset21", + "LocalizedCreativeAsset22", + "LocalizedCreativeAsset3", + "LocalizedCreativeAsset4", + "LocalizedCreativeAsset5", + "LocalizedCreativeAsset7", + "LocalizedCreativeAsset8", + "LocalizedCreativeAsset9", + ), + "core.macro_bearing_url": ( + "MacroBearingUrl3", + "MacroBearingUrl4", + ), + "core.macro_resolution_capability": ( + "MacroProcessingCapability", + "MappingStatus", + "Operation", + ), + "core.macro_resolution_result": ("MacroResolutionResult",), + "core.material_deadline": ("MaterialDeadline",), + "core.mcp_webhook_payload": ("McpWebhookPayload",), + "core.measurement_readiness": ("MeasurementReadiness",), + "core.measurement_terms": ("MeasurementTerms",), + "core.measurement_window": ("MeasurementWindow",), + "core.media_buy": ("MediaBuy",), + "core.media_buy_available_action": ("MediaBuyAvailableAction",), + "core.media_buy_available_action_id": ("MediaBuyAvailableActionId",), + "core.media_buy_change_term_id": ("MediaBuyChangeTermId",), + "core.media_buy_features": ("MediaBuyFeatures",), + "core.media_buy_frequency_cap": ("MediaBuyFrequencyCap",), + "core.media_buy_frequency_cap_capability": ("MediaBuyFrequencyCapCapability",), + "core.media_buy_frequency_cap_requirement": ("MediaBuyFrequencyCapRequirement",), + "core.media_buy_frequency_cap_support": ("MediaBuyFrequencyCapSupport",), + "core.media_buy_legacy_terms_ref": ("MediaBuyTermsReference",), + "core.media_buy_support": ("ProductMediaBuySupport",), + "core.media_buy_support_requirements": ("ProductMediaBuySupportRequirements",), + "core.missing_metric": ( + "MissingMetric", + "MissingMetric1", + "MissingMetric2", + ), + "core.negative_keyword": ("NegativeKeyword",), + "core.notification_config": ("NotificationConfig",), + "core.offering": ( + "GeoTargets", + "Offering", + ), + "core.offering_asset_group": ( + "Items", + "OfferingAssetGroup", + ), + "core.operator_identity": ("OperatorIdentity",), + "core.operator_unit": ("OperatorUnit",), + "core.opportunity_context": ( + "CloseReason", + "Intent", + "OpportunityContext", + "Phase", + ), + "core.optimization_goal": ( + "OptimizationGoal10", + "OptimizationGoal8", + "OptimizationGoal9", + "Target14", + "Target15", + "Target16", + "Target17", + "Target18", + "Target19", + ), + "core.outcome_measurement": ("OutcomeMeasurement",), + "core.outcome_target_cost_per": ("OutcomeTargetCostPer",), + "core.overlay": ( + "Bounds", + "Overlay", + "Visual", + ), + "core.package": ("Package",), + "core.package_delivery_metric_value": ("PackageDeliveryMetricValue",), + "core.package_format_snapshot": ( + "PackageFormatSnapshot", + "PackageFormatSnapshot1", + "PackageFormatSnapshot10", + "PackageFormatSnapshot11", + "PackageFormatSnapshot12", + "PackageFormatSnapshot13", + "PackageFormatSnapshot14", + "PackageFormatSnapshot15", + "PackageFormatSnapshot16", + "PackageFormatSnapshot17", + "PackageFormatSnapshot18", + "PackageFormatSnapshot19", + "PackageFormatSnapshot2", + "PackageFormatSnapshot20", + "PackageFormatSnapshot21", + "PackageFormatSnapshot22", + "PackageFormatSnapshot23", + "PackageFormatSnapshot24", + "PackageFormatSnapshot25", + "PackageFormatSnapshot26", + "PackageFormatSnapshot27", + "PackageFormatSnapshot28", + "PackageFormatSnapshot29", + "PackageFormatSnapshot3", + "PackageFormatSnapshot30", + "PackageFormatSnapshot31", + "PackageFormatSnapshot32", + "PackageFormatSnapshot33", + "PackageFormatSnapshot4", + "PackageFormatSnapshot5", + "PackageFormatSnapshot6", + "PackageFormatSnapshot7", + "PackageFormatSnapshot8", + "PackageFormatSnapshot9", + ), + "core.package_signal_targeting": ( + "PackageSignalTargeting", + "PackageSignalTargeting1", + "PackageSignalTargeting2", + "PackageSignalTargeting3", + "PackageSignalTargeting4", + "PackageSignalTargeting5", + "PackageSignalTargeting6", + "PackageSignalTargeting7", + ), + "core.package_signal_targeting_group": ( + "Operator", + "PackageSignalTargetingGroup", + ), + "core.package_signal_targeting_groups": ("PackageSignalTargetingGroups",), + "core.package_targeting_resolution": ("PackageTargetingResolution",), + "core.pagination_request": ("PaginationRequest",), + "core.pagination_response": ("PaginationResponse",), + "core.performance_feedback": ( + "MeasurementPeriod", + "Metric7", + "PerformanceFeedback", + ), + "core.performance_feedback_assertion": ( + "ConfidenceInterval", + "PerformanceFeedbackAssertion", + ), + "core.performance_feedback_metric": ( + "PerformanceFeedbackMetric", + "PerformanceFeedbackMetric1", + "PerformanceFeedbackMetric2", + ), + "core.performance_standard": ("PerformanceStandard",), + "core.placement": ( + "Placement", + "ProductDoohPlacementAttributes", + "ProductDoohScreenResolution", + ), + "core.placement_definition": ( + "FormatOptions", + "PlacementDefinition", + "PublisherDoohPlacementAttributes", + "PublisherDoohScreenResolution", + ), + "core.placement_delivery_metrics": ("PlacementDeliveryMetrics",), + "core.placement_evidence": ("PlacementEvidence",), + "core.placement_identity": ( + "PlacementIdentity", + "PlacementIdentity1", + "PlacementIdentity2", + ), + "core.placement_presentation": ( + "BoxDecoration", + "Canvas", + "Color", + "CreativeSlot", + "Fit", + "ImageDecoration", + "ImageRef", + "Layer", + "PlacementPresentationDocument", + "Rectangle", + "TextDecoration", + ), + "core.placement_property_delivery_metrics": ("PlacementPropertyDeliveryMetrics",), + "core.placement_ref": ("PlacementReference",), + "core.placement_selection": ( + "PlacementSelection1", + "PlacementSelection2", + ), + "core.planned_delivery": ( + "Geo", + "PlannedDelivery", + ), + "core.platform_extension_ref": ("PlatformExtensionReference",), + "core.positive_postal_area_support": ("PositivePostalAreaSupport",), + "core.postal_area": ( + "PostalArea", + "PostalArea1", + "PostalArea11", + "PostalArea110", + "PostalArea111", + "PostalArea112", + "PostalArea113", + "PostalArea114", + "PostalArea115", + "PostalArea116", + "PostalArea117", + "PostalArea118", + "PostalArea119", + "PostalArea12", + "PostalArea120", + "PostalArea121", + "PostalArea13", + "PostalArea14", + "PostalArea15", + "PostalArea16", + "PostalArea17", + "PostalArea18", + "PostalArea19", + "PostalArea2", + "System1", + "System2", + "System3", + "System9", + ), + "core.postal_area_support": ( + "CAEnum", + "GBEnum", + "ME", + "PostalAreaSupport", + "PostalAreaSupportAdditionalPropertyEnum", + ), + "core.postal_country_system": ( + "PostalCountrySystem", + "PostalCountrySystem1", + "PostalCountrySystem10", + "PostalCountrySystem2", + "PostalCountrySystem3", + "PostalCountrySystem4", + "PostalCountrySystem5", + "PostalCountrySystem6", + "PostalCountrySystem7", + "PostalCountrySystem8", + "PostalCountrySystem9", + "System11", + "System12", + "System13", + "System19", + ), + "core.presentation_ref": ("PlacementPresentationReference",), + "core.preview_provider": ( + "PublisherDesignatedPreviewProvider", + "Route", + ), + "core.preview_renderer_metadata": ( + "PreviewRendererMetadata", + "RenderingOrigin", + ), + "core.price": ("Price",), + "core.pricing_option": ("PricingOption",), + "core.principal_changed_webhook": ("PrincipalChangedWebhook",), + "core.principal_declarations": ( + "AgentDeclarations", + "AsyncAdcpVersion", + "WebhookSigningAlgorithm", + ), + "core.principal_declarations_state": ( + "Axis", + "Exclusion", + "PrincipalDeclarationsState", + ), + "core.principal_state": ( + "DestinationRef", + "PrincipalState", + "RetiredDestination", + ), + "core.product": ( + "AudienceActivation", + "ConversionTracking", + "DeliveryMeasurement", + "MaterialSubmission", + "MetricOptimization", + "Product", + "ProductCard", + "ProductCardDetailed", + "PublisherProperty81", + "PublisherProperty82", + "PublisherProperty83", + "PublisherProperty84", + "PublisherProperty85", + "PublisherProperty86", + "PublisherProperty87", + "Specification", + "SupportedMetric", + "SupportedTarget5", + "SupportedViewDuration", + ), + "core.product_allocation": ("ProductAllocation",), + "core.product_allowed_action": ("ProductAllowedAction",), + "core.product_audience_evidence_requirements": ( + "AcceptedAttestationIssuers", + "AcceptedAttestationIssuers1", + "AcceptedAttestationIssuers2", + "AcceptedAttestationIssuers3", + "ProductAudienceEvidenceRequirements", + ), + "core.product_card_reference_asset": ("ProductCardReferenceAsset",), + "core.product_change_map": ( + "ProductChangeMap", + "ProductChangeMap1", + ), + "core.product_execution_requirement": ( + "Connection", + "ProductExecutionRequirement", + "ProductExecutionRequirement1", + "ProductExecutionRequirement2", + "ProductExecutionRequirement3", + ), + "core.product_filters": ( + "AudienceActivationMethods", + "AudienceActivationMethods1", + "AudienceActivationMethods2", + "AudienceActivationMethods3", + "AudienceActivationMethods4", + "AudienceActivationMethods5", + "Keyword", + "ProductFilters", + "RequiredGeoTargetingItem", + "SignalTargetingItem", + "SignalTargetingItem1", + "SignalTargetingItem2", + "SignalTargetingItem3", + "SignalTargetingItem4", + "SignalTargetingItem5", + "SignalTargetingItem6", + "SignalTargetingItem7", + ), + "core.product_format_declaration": ( + "ProductFormatDeclaration", + "ProductFormatDeclaration1", + "ProductFormatDeclaration10", + "ProductFormatDeclaration11", + "ProductFormatDeclaration12", + "ProductFormatDeclaration13", + "ProductFormatDeclaration14", + "ProductFormatDeclaration15", + "ProductFormatDeclaration16", + "ProductFormatDeclaration2", + "ProductFormatDeclaration3", + "ProductFormatDeclaration4", + "ProductFormatDeclaration5", + "ProductFormatDeclaration6", + "ProductFormatDeclaration7", + "ProductFormatDeclaration8", + "ProductFormatDeclaration9", + ), + "core.product_identity": ("ProductIdentity",), + "core.product_offer_filters": ( + "AvailabilityHorizon", + "ProductOfferFilters", + "RequiredPerformanceStandard", + ), + "core.product_signal_targeting_option": ( + "ActivationStatus", + "AllowedTargetingMode", + "ProductSignalTargetingOption", + ), + "core.product_targeting_resolution": ("ProductTargetingResolution",), + "core.property": ("Property",), + "core.property_delivery_metrics": ("PropertyDeliveryMetrics",), + "core.property_id": ("PropertyId",), + "core.property_list_ref": ("PropertyListReference",), + "core.property_ref": ("PropertyReference",), + "core.property_tag": ("PropertyTag",), + "core.proposal": ("Proposal",), + "core.protocol_envelope": ("ProtocolEnvelope",), + "core.provenance": ("VerifyAgent18",), + "core.publisher_property_selector": ( + "PublisherPropertySelector", + "PublisherPropertySelector1", + "PublisherPropertySelector2", + "PublisherPropertySelector3", + ), + "core.push_notification_config": ("PushNotificationConfig",), + "core.real_estate_item": ( + "ListingType", + "PropertyType", + "RealEstateItem", + ), + "core.reference_renderer": ("ReferenceRenderer",), + "core.registry_event": ( + "AgentProfilePayload", + "AuthorizationPayload", + "AuthorizationType", + "BadgeRole", + "ChangedFields", + "Classification", + "CollectionIdentifier", + "CollectionPayload", + "CompliancePayload", + "ComplianceStatus", + "DelegationType", + "GradingProfile", + "Market", + "Payload1", + "Payload10", + "Payload11", + "Payload12", + "Payload13", + "Payload14", + "Payload2", + "Payload3", + "Payload4", + "Payload5", + "Payload6", + "Payload7", + "Payload8", + "Payload9", + "PropertyPayload", + "PropertySource", + "PublisherAdagentsPayload", + "RegistryEvent", + "RegistryEvent1", + "RegistryEvent10", + "RegistryEvent11", + "RegistryEvent12", + "RegistryEvent13", + "RegistryEvent14", + "RegistryEvent15", + "RegistryEvent16", + "RegistryEvent17", + "RegistryEvent18", + "RegistryEvent19", + "RegistryEvent2", + "RegistryEvent20", + "RegistryEvent3", + "RegistryEvent4", + "RegistryEvent5", + "RegistryEvent6", + "RegistryEvent7", + "RegistryEvent8", + "RegistryEvent9", + "Storyboard", + "StoryboardStatus", + "StringArray", + "Tracks", + ), + "core.registry_feed_response": ( + "Freshness", + "RegistryFeedResponse", + ), + "core.reporting_adjustment": ( + "AccountingPeriod", + "ReportingAdjustment", + ), + "core.reporting_adjustment_receipt": ( + "ReportingAdjustmentReceipt", + "ReportingAdjustmentRejectionCode", + ), + "core.reporting_canonical_content_digest": ("ReportingCanonicalContentDigest",), + "core.reporting_canonicalization_contract": ( + "AdditionalItem", + "EmptyReport", + "GoldenVectors", + "OrderingEncoding", + "ReportingCanonicalizationContract", + "ReportingPrimaryKey", + ), + "core.reporting_capabilities": ("ReportingCapabilities",), + "core.reporting_consumer_status": ( + "ConsumerStatus", + "FailureCode", + "MismatchCode", + "ReportingConsumerStatus", + ), + "core.reporting_control_total": ( + "ReportingControlTotal", + "ReportingControlTotal1", + "ReportingControlTotal2", + ), + "core.reporting_coverage": ( + "Limitation", + "ReportingCoverage", + "ReportingMediaBuyId", + "ReportingPackageId", + ), + "core.reporting_dataset_share_destination": ( + "Recipient", + "ReportingCloud", + "ReportingDatasetShareDestination", + "ReportingDatasetShareDestination1", + "ReportingDatasetShareDestination2", + ), + "core.reporting_delivery_capabilities": ( + "OperationsContact", + "ReportingDeliveryCapabilities", + ), + "core.reporting_delivery_config": ( + "AuthoritativeParty", + "CoverageRequirement", + "ReportingDeliveryConfiguration", + ), + "core.reporting_delivery_config_state": ( + "ReportingDeliveryConfigLifecycleState", + "ReportingDeliveryConfigurationState", + ), + "core.reporting_delivery_method": ( + "ReportingDeliveryMethod", + "ReportingDeliveryMethod1", + "ReportingDeliveryMethod2", + "ReportingDeliveryMethod3", + "ReportingOrchestration", + ), + "core.reporting_delivery_offering": ( + "DestinationMode", + "ProducerIdentity", + "ReportingDeliveryOffering", + "ReportingDeliveryPattern", + "ReportingFeedPurpose", + "ReportingProfile", + ), + "core.reporting_delivery_offering_id": ("ReportingDeliveryOfferingId",), + "core.reporting_delivery_ready_webhook": ( + "Readiness", + "ReportingDeliveryReadyWebhook", + ), + "core.reporting_file_compression": ("ReportingFileCompression",), + "core.reporting_file_entry": ("ReportingFileEntry",), + "core.reporting_file_manifest": ("ReportingFileManifest",), + "core.reporting_file_object_ref": ("ReportingFileObjectReference",), + "core.reporting_ledger_changed_webhook": ("ReportingLedgerChangedWebhook",), + "core.reporting_materialization": ("ReportingMaterialization",), + "core.reporting_native_version_ref": ("ReportingNativeVersionReference",), + "core.reporting_obligation": ( + "ProductionStatus", + "ReconciliationStatus", + "ReportingObligation", + ), + "core.reporting_receipt": ( + "RejectionCode", + "ReportingReceipt", + ), + "core.reporting_reconciliation_mode": ("ReportingReconciliationMode",), + "core.reporting_reliability_statistics": ( + "AdjustmentMagnitudeItem", + "Basis", + "LatencyPercentiles", + "ReportingReliabilityMeasurementPeriod", + "ReportingReliabilityStatistics", + ), + "core.reporting_report_definition": ( + "Aggregation", + "Calendar", + "ContractVersion", + "FinalityPolicies", + "FinalityPolicies1", + "FinalityPolicies2", + "ReportCalendarTimezoneBasis", + "ReportingReportDefinition", + "RestatementPolicy", + ), + "core.reporting_resource": ( + "Immutability", + "ReportingReaderCompatibilityItem", + "ReportingResource", + ), + "core.reporting_revision": ( + "DataThroughPrecision", + "FinalityBasis", + "ReportingRevision", + ), + "core.reporting_schedule": ( + "ReportingSchedule", + "ReportingScheduleAlignment", + ), + "core.reporting_schedule_offering": ( + "PeriodAnchorPolicy", + "PeriodTimezonePolicy", + "ReportingScheduleOffering", + ), + "core.reporting_status_changed_webhook": ( + "IssueId", + "ReportingStatusChangedWebhook", + ), + "core.reporting_status_issue": ( + "IssueState", + "RecommendedAction", + "ReportingStatusIssue", + "ReportingStatusSeverity", + "ResponsibleParty", + ), + "core.reporting_verification": ( + "NativeCommitEvidence", + "ObservedThrough", + "PhysicalChecksums", + "PhysicalChecksums1", + "ReportingVerification", + "VerificationPath", + ), + "core.reporting_verification_profile": ("ReportingVerificationProfile",), + "core.reporting_verification_profile_set": ( + "ReportingVerificationProfileSet", + "ReportingVerificationProfileSetEnum", + ), + "core.reporting_webhook": ( + "ReportingFrequency", + "ReportingWebhook", + ), + "core.reporting_write_destination": ( + "ReportingWriteDestination", + "ReportingWriteDestination1", + "ReportingWriteDestination2", + ), + "core.representation_destination": ("RepresentationDestination",), + "core.representation_rejection": ("RepresentationRejection",), + "core.representation_selection": ( + "RepresentationSelection", + "ResolvedBy", + ), + "core.requirements.asset_requirements": ("AssetRequirements",), + "core.requirements.audio_asset_requirements": ( + "AudioAssetRequirements", + "SampleRate", + ), + "core.requirements.catalog_field_binding": ( + "AssetPoolBinding", + "CatalogFieldBinding", + "CatalogFieldBinding1", + "PerItemBindings", + "ScalarBinding", + ), + "core.requirements.catalog_requirements": ("CatalogRequirements",), + "core.requirements.css_asset_requirements": ("CssAssetRequirements",), + "core.requirements.daast_asset_requirements": ("DaastAssetRequirements",), + "core.requirements.html_asset_requirements": ( + "HtmlAssetRequirements", + "Sandbox", + ), + "core.requirements.image_asset_requirements": ( + "Bleed", + "Bleed1", + "ImageAssetRequirements", + "PixelRatio", + ), + "core.requirements.javascript_asset_requirements": ( + "JavascriptAssetRequirements", + "ModuleType", + ), + "core.requirements.markdown_asset_requirements": ("MarkdownAssetRequirements",), + "core.requirements.offering_asset_constraint": ("OfferingAssetConstraint",), + "core.requirements.text_asset_requirements": ("TextAssetRequirements",), + "core.requirements.url_asset_requirements": ( + "Protocol", + "UrlAssetRequirements", + ), + "core.requirements.vast_asset_requirements": ("VastAssetRequirements",), + "core.requirements.video_asset_requirements": ( + "AudioCodec", + "AudioSampleRate", + "FrameRate", + "VideoAssetRequirements", + ), + "core.requirements.webhook_asset_requirements": ("WebhookAssetRequirements",), + "core.response": ("ProtocolResponse",), + "core.response_payload_jws_envelope": ( + "ResponsePayload", + "ResponsePayloadJwsEnvelope", + ), + "core.rights_attestation_evaluation": ( + "Issuer5", + "Issuer6", + "Reference", + "RightsAttestationEvaluation", + "Subject10", + "Subject19", + ), + "core.rights_constraint": ( + "ApprovalStatus", + "ExcludedCountry", + "GrantStatus", + "Issuer8", + "Issuer9", + "Restriction", + "RightsAgent", + "RightsConstraint", + "Subject20", + "Subject29", + ), + "core.seller_agent_ref": ("SellerAgentReference",), + "core.signal_coverage_forecast": ( + "BucketCompleteness", + "BucketSemantics", + "Point", + "SignalCoverageForecast", + ), + "core.signal_definition": ( + "AudienceScope", + "SignalDefinition", + "Tag", + ), + "core.signal_definition_enrichment": ("SignalDefinitionEnrichment",), + "core.signal_filters": ("SignalFilters",), + "core.signal_id": ( + "SignalId8", + "SignalId9", + ), + "core.signal_listing": ("SignalListing",), + "core.signal_modeling_disclosure": ( + "Audience", + "SignalModelingDisclosure", + ), + "core.signal_pricing": ( + "VendorPricing", + "VendorPricing1", + "VendorPricing2", + "VendorPricing3", + "VendorPricing4", + "VendorPricing5", + ), + "core.signal_pricing_option": ("SignalPricingOption",), + "core.signal_ref": ( + "SignalRef", + "SignalRef1", + "SignalRef2", + "SignalRef3", + ), + "core.signal_selection_group_rule": ("SignalSelectionGroupRule",), + "core.signal_targeting": ( + "SignalTargeting", + "SignalTargeting1", + "SignalTargeting2", + "SignalTargeting3", + ), + "core.signal_targeting_expression": ( + "SignalTargetingExpression", + "SignalTargetingExpression1", + "SignalTargetingExpression2", + "SignalTargetingExpression3", + ), + "core.signal_targeting_rules": ( + "ResolutionModel", + "SignalTargetingRules", + ), + "core.sla_window": ("SlaWindow",), + "core.special": ("Special",), + "core.spot_reporting_capability": ("SpotReportingCapability",), + "core.start_timing": ("StartTiming",), + "core.store_item": ("StoreItem",), + "core.talent": ("Talent",), + "core.targeting": ( + "AgeRestriction", + "TargetingOverlay", + ), + "core.targeting_input": ("TargetingOverlayInput",), + "core.targeting_modification": ( + "Path", + "Selector", + "TargetingModification", + "TargetingModification1", + "TargetingModification2", + ), + "core.targeting_overlay_requirements": ( + "BrowserRequirement", + "BrowserRequirement1", + "DaypartRequirement", + "DaypartRequirement1", + "KeywordRequirement", + "KeywordRequirement1", + "MetroRequirement", + "MetroRequirement1", + "Required", + "TargetingOverlayRequirements", + ), + "core.targeting_overlay_support": ( + "BrowserSupport", + "BrowserSupport1", + "CountrySupport", + "CountrySupport1", + "DaypartSupport", + "DaypartSupport1", + "IanaTimezones", + "KeywordSupport", + "KeywordSupport1", + "MetroSupport", + "MetroSupport1", + "PlaceCatalogSupport", + "PlaceSupport", + "Supported", + "TargetingOverlaySupport", + ), + "core.targeting_unknown_age_eligibility_constraint": ( + "TargetingUnknownAgeEligibilityConstraint", + ), + "core.targeting_verified_age_basis_constraint": ("TargetingVerifiedAgeBasisConstraint",), + "core.tasks_get_request": ("TasksGetRequest",), + "core.tasks_get_response": ( + "Details", + "HistoryItem", + "Progress", + "TasksGetResponse", + ), + "core.tasks_list_request": ( + "Filters", + "Sort", + "TasksListRequest", + ), + "core.tasks_list_response": ( + "DomainBreakdown", + "QuerySummary", + "SortApplied", + "TasksListResponse", + ), + "core.tracker_execution_contract": ("TrackerExecutionContract",), + "core.tracker_execution_selector": ( + "TrackerExecutionSelector", + "TrackerExecutionSelector1", + "TrackerExecutionSelector2", + "TrackerExecutionSelector3", + ), + "core.transformer": ( + "BrandAgent", + "InputFormat", + "InputFormat1", + "InputFormat10", + "InputFormat11", + "InputFormat12", + "InputFormat13", + "InputFormat14", + "InputFormat15", + "InputFormat16", + "InputFormat17", + "InputFormat18", + "InputFormat19", + "InputFormat2", + "InputFormat20", + "InputFormat21", + "InputFormat22", + "InputFormat23", + "InputFormat24", + "InputFormat25", + "InputFormat26", + "InputFormat27", + "InputFormat28", + "InputFormat29", + "InputFormat3", + "InputFormat30", + "InputFormat31", + "InputFormat32", + "InputFormat33", + "InputFormat4", + "InputFormat5", + "InputFormat6", + "InputFormat7", + "InputFormat8", + "InputFormat9", + "Multiplicity", + "OutputCapabilityId", + "Transformer", + "VariantDimension", + "VoiceSynthesisRefItem", + ), + "core.transformer_param": ( + "Option", + "TransformerParam", + "ValueSource", + ), + "core.truncation_sentinel": ( + "FieldTruncation", + "TruncationSentinel", + ), + "core.user_match": ("UserMatch",), + "core.vast_media_file_requirements": ( + "MimeType", + "VastMediafileRequirements", + ), + "core.vast_tracker_constraints": ( + "VastEvent", + "VastOffset", + "VastTarget", + "VastTrackerConstraints", + "VastVersions", + ), + "core.vehicle_item": ( + "BodyStyle", + "Condition", + "FuelType", + "Mileage", + "Transmission", + "VehicleItem", + ), + "core.vendor_metric_id": ("VendorMetricId",), + "core.vendor_metric_optimization": ("VendorMetricOptimization",), + "core.vendor_metric_optimization_supported_metric": ( + "VendorMetricOptimizationSupportedMetric", + ), + "core.vendor_metric_value": ("VendorMetricValue",), + "core.vendor_pricing_option": ( + "AppliesToOutputCapabilityId", + "VendorPricingOption", + "VendorPricingOption1", + "VendorPricingOption10", + "VendorPricingOption11", + "VendorPricingOption2", + "VendorPricingOption3", + "VendorPricingOption4", + "VendorPricingOption5", + "VendorPricingOption6", + "VendorPricingOption7", + "VendorPricingOption8", + "VendorPricingOption9", + ), + "core.verification_token_claims": ( + "AgenticadvertisingOrgVerificationTokenClaims", + "VerificationTokenGradingProfile", + "VerificationTokenMode", + ), + "core.version_envelope": ("AdcpVersionEnvelope",), + "core.warning": ("Warning",), + "core.warning_resource": ("WarningAffectedResource",), + "core.webhook_activity_record": ("WebhookActivityRecord",), + "core.webhook_challenge": ("WebhookChallenge",), + "core.webhook_challenge_response": ( + "WebhookChallengeResponse", + "WebhookChallengeResponseBranches", + "WebhookChallengeResponseChallenge", + "WebhookChallengeResponseToken", + ), + "core.wholesale_feed_event": ( + "AffectedEntityType", + "AppliesTo", + "AppliesTo1", + "AppliesTo2", + "Payload17", + "Payload18", + "Payload20", + "Payload21", + "Payload22", + "Payload23", + "Payload24", + "Payload25", + "RemovalReason", + "Signal", + "WholesaleFeedEvent", + "WholesaleFeedEvent1", + "WholesaleFeedEvent2", + "WholesaleFeedEvent3", + "WholesaleFeedEvent4", + "WholesaleFeedEvent5", + "WholesaleFeedEvent6", + "WholesaleFeedEvent7", + "WholesaleFeedEvent8", + "WholesaleFeedEvent9", + ), + "core.wholesale_feed_webhook": ( + "CacheScope", + "NotificationType", + "WholesaleFeedWebhook", + ), + "core.x_entity_types": ("XEntityTypes",), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ @@ -1901,6 +3998,9 @@ "AudienceActivationMethods4", "AudienceActivationMethods5", "AudienceCharacteristic", + "AudienceCharacteristicBranches", + "AudienceCharacteristicRange", + "AudienceCharacteristicValue", "AudienceEvidence", "AudienceEvidencePin", "AudienceEvidenceRequirements", @@ -2067,6 +4167,10 @@ "CatalogSelection", "CatalogType", "Catchment", + "CatchmentBranches", + "CatchmentGeometry", + "CatchmentRadius", + "CatchmentTransportModeTravelTime", "Category", "ChangedFields", "Classification", @@ -2123,6 +4227,9 @@ "CreateMediaBuySubmitted", "CreateMediaBuyWorking", "CreativeAsset", + "CreativeAssetBranches", + "CreativeAssetV1CreativeNamedFormatReference", + "CreativeAssetV2CreativeCanonicalFormatKind", "CreativeAssets", "CreativeAssets1", "CreativeAssignment", @@ -2136,6 +4243,9 @@ "CreativeLocalization", "CreativeLocalizationReadback", "CreativeManifest", + "CreativeManifestBranches", + "CreativeManifestCanonicalFormatManifest", + "CreativeManifestNamedFormatManifest", "CreativeOperationFormatDeclaration", "CreativePolicy", "CreativeRepresentation", @@ -2272,6 +4382,7 @@ "ForecastRange", "ForecastRateRange", "ForecastVendorMetricValue", + "FormatAssetUnion", "FormatCard", "FormatCardDetailed", "FormatKind", @@ -2286,7 +4397,13 @@ "FrequencyCapConstraints", "FrequencyCapDurationUnit", "FrequencyCapImpressionConstraints", + "FrequencyCapImpressionConstraintsAllowedValues", + "FrequencyCapImpressionConstraintsBranches", + "FrequencyCapImpressionConstraintsMaximumMinimum", "FrequencyCapIntervalConstraints", + "FrequencyCapIntervalConstraintsAllowedIntervals", + "FrequencyCapIntervalConstraintsBranches", + "FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval", "FrequencyCapRequirements", "Freshness", "FuelType", @@ -2315,6 +4432,9 @@ "GeographicRegionSupport", "GetCreativeFeaturesSubmitted", "GetGeographicPlaceResolutionRequest", + "GetGeographicPlaceResolutionRequestBranches", + "GetGeographicPlaceResolutionRequestQ", + "GetGeographicPlaceResolutionRequestValue", "GetGeographicPlaceResolutionResponse", "GetProductsInputRequired", "GetProductsSubmitted", @@ -2326,6 +4446,7 @@ "GovernanceAgent", "GradingProfile", "GrantStatus", + "GroupFormatAssetUnion", "HistoryItem", "HotelItem", "HtmlAssetRequirements", @@ -3101,6 +5222,8 @@ "Trust", "UnavailableReason", "UniversalMacro", + "UnknownFormatAsset", + "UnknownGroupAsset", "UnknownHandling", "UpdateFrequency", "UpdateMediaBuyInputRequired", @@ -3173,6 +5296,9 @@ "WebhookAssetRequirements", "WebhookChallenge", "WebhookChallengeResponse", + "WebhookChallengeResponseBranches", + "WebhookChallengeResponseChallenge", + "WebhookChallengeResponseToken", "WebhookResponseType", "WebhookSecurityMethod", "WebhookSigningAlgorithm", diff --git a/src/adcp/types/domains/core/account.py b/src/adcp/types/domains/core/account.py index 0142c4c4c..12204ab7c 100644 --- a/src/adcp/types/domains/core/account.py +++ b/src/adcp/types/domains/core/account.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/account.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..enums import account_scope as account_scope_1 from ..enums import account_status, billing_party, cloud_storage_protocol @@ -20,6 +20,12 @@ from . import reporting_delivery_config_state, webhook_activity_record +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class CreditLimit(AdCPBaseModel): amount: Annotated[StrictFloat, Field(ge=0.0)] currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] @@ -264,3 +270,29 @@ class Account(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> Account: + # Compiled from core/account.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'identity_change')) or _c.supplied(self, 'revision') + ): + raise PydanticCustomError( + 'allOf', + 'Account violates a constraint its schema states outside its properties (core/account.json, root conjunct 0) over identity_change, revision', + {'fields': 'identity_change, revision'}, + ) + if not ( + not (_c.sent(self, 'destination_billing_entity')) + ): + raise PydanticCustomError( + 'allOf', + 'Account violates a constraint its schema states outside its properties (core/account.json, root conjunct 1) over destination_billing_entity', + {'fields': 'destination_billing_entity'}, + ) + return self diff --git a/src/adcp/types/domains/core/account_timezone_capability.py b/src/adcp/types/domains/core/account_timezone_capability.py index bf7b14481..51ae8837a 100644 --- a/src/adcp/types/domains/core/account_timezone_capability.py +++ b/src/adcp/types/domains/core/account_timezone_capability.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/account_timezone_capability.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,8 +8,14 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel +from pydantic import ConfigDict, Field, model_validator + + +from adcp.types import _conditions as _c from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field + + +from pydantic_core import PydanticCustomError class Mode(StrEnum): @@ -57,3 +63,45 @@ class AccountTimezoneCapability(AdCPBaseModel): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AccountTimezoneCapability: + # Compiled from core/account-timezone-capability.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((not (_c.sent(self, 'account_selection') or _c.sent(self, 'supported_timezones')) and _c.sent(self, 'fixed_timezone')) if _c.descend(self, 'mode', lambda mode: mode == 'seller_fixed') else True) + ): + raise PydanticCustomError( + 'allOf', + 'AccountTimezoneCapability violates a constraint its schema states outside its properties (core/account-timezone-capability.json, root conjunct 0) over account_selection, fixed_timezone, mode, supported_timezones', + {'fields': 'account_selection, fixed_timezone, mode, supported_timezones'}, + ) + if not ( + ((not (_c.sent(self, 'fixed_timezone')) and _c.sent(self, 'account_selection')) if _c.descend(self, 'mode', lambda mode: mode == 'account_fixed') else True) + ): + raise PydanticCustomError( + 'allOf', + 'AccountTimezoneCapability violates a constraint its schema states outside its properties (core/account-timezone-capability.json, root conjunct 1) over account_selection, fixed_timezone, mode', + {'fields': 'account_selection, fixed_timezone, mode'}, + ) + if not ( + (_c.sent(self, 'supported_timezones') if (_c.descend(self, 'account_selection', lambda account_selection: account_selection == 'buyer_selected') and _c.sent(self, 'account_selection')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AccountTimezoneCapability violates a constraint its schema states outside its properties (core/account-timezone-capability.json, root conjunct 2) over account_selection, supported_timezones', + {'fields': 'account_selection, supported_timezones'}, + ) + if not ( + (not (_c.sent(self, 'supported_timezones')) if (_c.descend(self, 'account_selection', lambda account_selection: account_selection == 'seller_assigned') and _c.sent(self, 'account_selection')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AccountTimezoneCapability violates a constraint its schema states outside its properties (core/account-timezone-capability.json, root conjunct 3) over account_selection, supported_timezones', + {'fields': 'account_selection, supported_timezones'}, + ) + return self diff --git a/src/adcp/types/domains/core/agent_notification_config.py b/src/adcp/types/domains/core/agent_notification_config.py index af2f567ad..d98a79959 100644 --- a/src/adcp/types/domains/core/agent_notification_config.py +++ b/src/adcp/types/domains/core/agent_notification_config.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: core/agent_notification_config.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from pydantic import AnyUrl, ConfigDict, Field, StrictBool, model_validator from ..enums import auth_scheme, notification_type from . import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Authentication(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -79,3 +85,21 @@ class AgentNotificationConfig(AdCPBaseModel): ), ] = True ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AgentNotificationConfig: + # Compiled from core/agent-notification-config.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.descend(self, 'all_authorized_accounts', lambda all_authorized_accounts: all_authorized_accounts == True) and _c.sent(self, 'all_authorized_accounts')) if (_c.descend(self, 'event_types', lambda event_types: _c.some(event_types, lambda item: item in ['creative.status_changed', 'creative.assignment_changed', 'indicators.changed', 'creative.purged', 'account.status_changed', 'account.change_recorded', 'product.created', 'product.updated', 'product.priced', 'product.removed', 'signal.created', 'signal.updated', 'signal.priced', 'signal.removed', 'wholesale_feed.bulk_change'])) and _c.sent(self, 'event_types')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AgentNotificationConfig violates a constraint its schema states outside its properties (core/agent-notification-config.json, root conjunct 0) over all_authorized_accounts, event_types', + {'fields': 'all_authorized_accounts, event_types'}, + ) + return self diff --git a/src/adcp/types/domains/core/agent_reporting_destination_state.py b/src/adcp/types/domains/core/agent_reporting_destination_state.py index 1cbef19d0..22f4d63ff 100644 --- a/src/adcp/types/domains/core/agent_reporting_destination_state.py +++ b/src/adcp/types/domains/core/agent_reporting_destination_state.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/agent_reporting_destination_state.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,13 +8,19 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator from ..enums import reporting_destination_setup_state from . import agent_reporting_destination, error +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class PriorDestinationRef(ScalarStr): __slots__ = () _constraints = {'max_length': 255, 'min_length': 1} @@ -99,3 +105,37 @@ class AgentReportingDestinationState(AdCPBaseModel): max_length=16, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AgentReportingDestinationState: + # Compiled from core/agent-reporting-destination-state.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'setup') if (_c.descend(self, 'state', lambda state: state == 'action_required') and _c.sent(self, 'state')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AgentReportingDestinationState violates a constraint its schema states outside its properties (core/agent-reporting-destination-state.json, root conjunct 0) over setup, state', + {'fields': 'setup, state'}, + ) + if not ( + (_c.descend(self, 'configuration', lambda configuration: _c.descend(configuration, 'active', lambda active: active == False)) if (_c.descend(self, 'state', lambda state: state == 'inactive') and _c.sent(self, 'state')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AgentReportingDestinationState violates a constraint its schema states outside its properties (core/agent-reporting-destination-state.json, root conjunct 1) over active, configuration, state', + {'fields': 'active, configuration, state'}, + ) + if not ( + (_c.descend(self, 'state', lambda state: state in ['inactive', 'rejected']) if (_c.descend(self, 'configuration', lambda configuration: _c.descend(configuration, 'active', lambda active: active == False) and _c.sent(configuration, 'active')) and _c.sent(self, 'configuration')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AgentReportingDestinationState violates a constraint its schema states outside its properties (core/agent-reporting-destination-state.json, root conjunct 2) over active, configuration, state', + {'fields': 'active, configuration, state'}, + ) + return self diff --git a/src/adcp/types/domains/core/assets/daast_tracker_asset.py b/src/adcp/types/domains/core/assets/daast_tracker_asset.py index 065ec6771..13886dbe3 100644 --- a/src/adcp/types/domains/core/assets/daast_tracker_asset.py +++ b/src/adcp/types/domains/core/assets/daast_tracker_asset.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/assets/daast_tracker_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ...enums import daast_tracking_event from .. import macro_bearing_url @@ -17,6 +17,12 @@ from . import asset_union +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Field1(StrEnum): url = 'url' markup = 'markup' @@ -95,3 +101,21 @@ class DaastTrackerAsset(AdCPBaseModel): description='Provenance metadata for this asset, overrides manifest-level provenance.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> DaastTrackerAsset: + # Compiled from core/assets/daast-tracker-asset.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'offset') if (_c.descend(self, 'daast_event', lambda daast_event: daast_event == 'progress') and _c.sent(self, 'daast_event')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'DaastTrackerAsset violates a constraint its schema states outside its properties (core/assets/daast-tracker-asset.json, root conjunct 0) over daast_event, offset', + {'fields': 'daast_event, offset'}, + ) + return self diff --git a/src/adcp/types/domains/core/assets/pixel_tracker_asset.py b/src/adcp/types/domains/core/assets/pixel_tracker_asset.py index e7c9ff657..8ad14f2b4 100644 --- a/src/adcp/types/domains/core/assets/pixel_tracker_asset.py +++ b/src/adcp/types/domains/core/assets/pixel_tracker_asset.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/assets/pixel_tracker_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ...enums import pixel_tracking_event from .. import macro_bearing_url @@ -17,6 +17,12 @@ from . import asset_union +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Method(StrEnum): img = 'img' js = 'js' @@ -95,3 +101,21 @@ class PixelTrackerAsset(AdCPBaseModel): description='Provenance metadata for this asset, overrides manifest-level provenance.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> PixelTrackerAsset: + # Compiled from core/assets/pixel-tracker-asset.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'custom_event_name') if (_c.descend(self, 'event', lambda event: event == 'custom') and _c.sent(self, 'event')) else not (_c.sent(self, 'custom_event_name'))) + ): + raise PydanticCustomError( + 'allOf', + 'PixelTrackerAsset violates a constraint its schema states outside its properties (core/assets/pixel-tracker-asset.json, root conjunct 0) over custom_event_name, event', + {'fields': 'custom_event_name, event'}, + ) + return self diff --git a/src/adcp/types/domains/core/assets/published_post_asset.py b/src/adcp/types/domains/core/assets/published_post_asset.py index a9236e5a0..05d0f8bc4 100644 --- a/src/adcp/types/domains/core/assets/published_post_asset.py +++ b/src/adcp/types/domains/core/assets/published_post_asset.py @@ -1,11 +1,11 @@ # generated by datamodel-codegen: # filename: core/assets/published_post_asset.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum -from typing import Annotated, Literal +from typing import Annotated, ClassVar, Literal from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator @@ -13,6 +13,9 @@ from .. import provenance as provenance_1 +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -118,19 +121,29 @@ class PublishedPostAsset(AdCPBaseModel): description='Provenance metadata for this reference asset, overrides manifest-level provenance.' ), ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'platform_post_id', 'post_url'}) @model_validator(mode='after') def _require_schema_required_group(self) -> PublishedPostAsset: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('post_url',), ('platform_post_id',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'PublishedPostAsset requires at least one of these field groups: post_url | platform_post_id', - ) + # Compiled from core/assets/published-post-asset.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # post_url + ( + _c.sent(self, 'post_url') + ) + # platform_post_id + or ( + _c.sent(self, 'platform_post_id') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'PublishedPostAsset requires at least one of these field groups: post_url | platform_post_id', + ) + return self diff --git a/src/adcp/types/domains/core/assets/vast_tracker_asset.py b/src/adcp/types/domains/core/assets/vast_tracker_asset.py index d6395ac3a..8842ccc48 100644 --- a/src/adcp/types/domains/core/assets/vast_tracker_asset.py +++ b/src/adcp/types/domains/core/assets/vast_tracker_asset.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/assets/vast_tracker_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ...enums import vast_tracking_event from .. import macro_bearing_url @@ -17,6 +17,12 @@ from . import asset_union +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Field1(StrEnum): url = 'url' markup = 'markup' @@ -96,3 +102,21 @@ class VastTrackerAsset(AdCPBaseModel): description='Provenance metadata for this asset, overrides manifest-level provenance.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> VastTrackerAsset: + # Compiled from core/assets/vast-tracker-asset.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'offset') if (_c.descend(self, 'vast_event', lambda vast_event: vast_event == 'progress') and _c.sent(self, 'vast_event')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerAsset violates a constraint its schema states outside its properties (core/assets/vast-tracker-asset.json, root conjunct 0) over offset, vast_event', + {'fields': 'offset, vast_event'}, + ) + return self diff --git a/src/adcp/types/domains/core/async_response_refs/creative/get_creative_features_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/creative/get_creative_features_async_response_submitted.py index 6b0bd0095..40fd3f813 100644 --- a/src/adcp/types/domains/core/async_response_refs/creative/get_creative_features_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/creative/get_creative_features_async_response_submitted.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/creative/get_creative_features_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ... import context as context_1 from ... import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class GetCreativeFeaturesSubmitted(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -46,3 +52,21 @@ class GetCreativeFeaturesSubmitted(AdCPBaseModel): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetCreativeFeaturesSubmitted: + # Compiled from core/async-response-refs/creative/get-creative-features-async-response-submitted.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'results') or _c.supplied(self, 'errors') or _c.supplied(self, 'detail_url') or _c.supplied(self, 'pricing_option_id') or _c.supplied(self, 'vendor_cost') or _c.supplied(self, 'currency') or _c.supplied(self, 'consumption')) + ): + raise PydanticCustomError( + 'allOf', + 'GetCreativeFeaturesSubmitted violates a constraint its schema states outside its properties (core/async-response-refs/creative/get-creative-features-async-response-submitted.json, root conjunct 0) over consumption, currency, detail_url, errors, pricing_option_id, results, vendor_cost', + {'fields': 'consumption, currency, detail_url, errors, pricing_option_id, results, vendor_cost'}, + ) + return self diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py index 5e4be1c86..98b2cdbe9 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/get_products_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ... import context as context_1 from ... import error from ... import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class GetProductsSubmitted(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -48,3 +54,21 @@ class GetProductsSubmitted(AdCPBaseModel): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetProductsSubmitted: + # Compiled from core/async-response-refs/media-buy/get-products-async-response-submitted.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'products') or _c.supplied(self, 'proposals') or _c.supplied(self, 'result')) + ): + raise PydanticCustomError( + 'allOf', + 'GetProductsSubmitted violates a constraint its schema states outside its properties (core/async-response-refs/media-buy/get-products-async-response-submitted.json, root conjunct 0) over products, proposals, result', + {'fields': 'products, proposals, result'}, + ) + return self diff --git a/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py index 35ec0661d..3f8378734 100644 --- a/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/signals/get_signals_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ... import context as context_1 from ... import error from ... import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class GetSignalsSubmitted(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -49,3 +55,21 @@ class GetSignalsSubmitted(AdCPBaseModel): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetSignalsSubmitted: + # Compiled from core/async-response-refs/signals/get-signals-async-response-submitted.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'signals') or _c.supplied(self, 'result')) + ): + raise PydanticCustomError( + 'allOf', + 'GetSignalsSubmitted violates a constraint its schema states outside its properties (core/async-response-refs/signals/get-signals-async-response-submitted.json, root conjunct 0) over result, signals', + {'fields': 'result, signals'}, + ) + return self diff --git a/src/adcp/types/domains/core/attestation_capabilities.py b/src/adcp/types/domains/core/attestation_capabilities.py index 9cd9b6537..238d2e303 100644 --- a/src/adcp/types/domains/core/attestation_capabilities.py +++ b/src/adcp/types/domains/core/attestation_capabilities.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/attestation_capabilities.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, RootModel +from pydantic import AnyUrl, ConfigDict, Field, RootModel, model_validator from . import attestation_issuer from . import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class SupportedDeliveryMethod(StrEnum): credential_uri = 'credential_uri' issuer_credential_id = 'issuer_credential_id' @@ -142,3 +148,21 @@ class AttestationCapabilities(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AttestationCapabilities: + # Compiled from core/attestation-capabilities.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'max_embedded_credential_bytes') if (_c.descend(self, 'supported_delivery_methods', lambda supported_delivery_methods: _c.some(supported_delivery_methods, lambda item: item == 'embedded')) and _c.sent(self, 'supported_delivery_methods')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AttestationCapabilities violates a constraint its schema states outside its properties (core/attestation-capabilities.json, root conjunct 0) over max_embedded_credential_bytes, supported_delivery_methods', + {'fields': 'max_embedded_credential_bytes, supported_delivery_methods'}, + ) + return self diff --git a/src/adcp/types/domains/core/attestation_evaluation.py b/src/adcp/types/domains/core/attestation_evaluation.py index 9a0536ab7..170b7864e 100644 --- a/src/adcp/types/domains/core/attestation_evaluation.py +++ b/src/adcp/types/domains/core/attestation_evaluation.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/attestation_evaluation.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from . import ext as ext_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Outcome(StrEnum): verified = 'verified' not_found = 'not_found' @@ -138,3 +144,29 @@ class AttestationEvaluation(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AttestationEvaluation: + # Compiled from core/attestation-evaluation.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'credential_digest') and _c.sent(self, 'proof_format')) if (_c.descend(self, 'outcome', lambda outcome: outcome in ['verified', 'expired', 'revoked', 'invalid', 'subject_mismatch', 'digest_mismatch']) and _c.sent(self, 'outcome')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AttestationEvaluation violates a constraint its schema states outside its properties (core/attestation-evaluation.json, root conjunct 0) over credential_digest, outcome, proof_format', + {'fields': 'credential_digest, outcome, proof_format'}, + ) + if not ( + (_c.sent(self, 'proof_format') if _c.sent(self, 'credential_digest') else True) + ): + raise PydanticCustomError( + 'allOf', + 'AttestationEvaluation violates a constraint its schema states outside its properties (core/attestation-evaluation.json, root conjunct 1) over credential_digest, proof_format', + {'fields': 'credential_digest, proof_format'}, + ) + return self diff --git a/src/adcp/types/domains/core/attestation_reference.py b/src/adcp/types/domains/core/attestation_reference.py index 1a90530f7..933b8babc 100644 --- a/src/adcp/types/domains/core/attestation_reference.py +++ b/src/adcp/types/domains/core/attestation_reference.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: core/attestation_reference.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated, Any, Literal +from typing import Annotated, Any, ClassVar, Literal from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator @@ -13,6 +13,9 @@ from . import ext as ext_1 +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -143,19 +146,37 @@ class AttestationReference(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'embedded_credential', 'locator'}) @model_validator(mode='after') def _require_schema_required_group(self) -> AttestationReference: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('locator',), ('embedded_credential',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'AttestationReference requires at least one of these field groups: locator | embedded_credential', - ) + # Compiled from core/attestation-reference.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # locator + ( + _c.sent(self, 'locator') + ) + # embedded_credential + or ( + _c.sent(self, 'embedded_credential') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'AttestationReference requires at least one of these field groups: locator | embedded_credential', + ) + if not ( + (_c.sent(self, 'content_digest') if (_c.sent(self, 'locator') and _c.sent(self, 'embedded_credential')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AttestationReference violates a constraint its schema states outside its properties (core/attestation-reference.json, root conjunct 0) over content_digest, embedded_credential, locator', + {'fields': 'content_digest, embedded_credential, locator'}, + ) + return self diff --git a/src/adcp/types/domains/core/audience_characteristic.py b/src/adcp/types/domains/core/audience_characteristic.py index a6be90ca3..a01a04894 100644 --- a/src/adcp/types/domains/core/audience_characteristic.py +++ b/src/adcp/types/domains/core/audience_characteristic.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: core/audience_characteristic.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Any, Annotated, Literal +from typing import Annotated, Any, ClassVar, Literal, TypeAlias from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool, StrictFloat, model_validator +from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool, StrictFloat, WrapValidator, model_validator + + +from adcp.types import _conditions as _c from pydantic_core import PydanticCustomError +from adcp.types._branch_dispatch import BranchDispatchMixin + + class Dimension(RootModel[AnyUrl]): root: Annotated[ AnyUrl, @@ -52,7 +58,25 @@ class Taxonomy(AdCPBaseModel): value_id: Annotated[str | None, Field(min_length=1)] = None -class AudienceCharacteristic(AdCPBaseModel): +class AudienceCharacteristic(BranchDispatchMixin, AdCPBaseModel): + """What every branch of core/audience-characteristic.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``AudienceCharacteristicValue`` + ``AudienceCharacteristicRange`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + AudienceCharacteristicBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + model_config = ConfigDict( extra='forbid', ) @@ -81,19 +105,163 @@ class AudienceCharacteristic(AdCPBaseModel): label: Annotated[ str | None, Field(description='Human-readable display label; never the comparison key.') ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'range', 'value'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'dimension'}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + AudienceCharacteristicValue, + AudienceCharacteristicRange, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'anyOf' @model_validator(mode='after') def _require_schema_required_group(self) -> AudienceCharacteristic: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('value',), ('range',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'AudienceCharacteristic requires at least one of these field groups: value | range', + # Compiled from core/audience-characteristic.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # value + ( + not (_c.sent(self, 'range')) and _c.sent(self, 'value') + ) + # range + or ( + not (_c.sent(self, 'value')) and _c.sent(self, 'range') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'AudienceCharacteristic requires at least one of these field groups: value | range', + ) + if not ( + ((_c.descend(self, 'range', lambda range: _c.descend(range, 'max', lambda max: _c.within(max, ge=0) and _c.json_type(max, 'integer')) and _c.descend(range, 'min', lambda min: _c.within(min, ge=0) and _c.json_type(min, 'integer'))) and _c.descend(self, 'value', lambda value: [_c.within(value, ge=0) and _c.json_type(value, 'integer'), _c.every(value, lambda item: _c.within(item, ge=0) and _c.json_type(item, 'integer')) and _c.length(value, minimum=1) and _c.json_type(value, 'array') and _c.unique(value)].count(True) == 1)) if _c.descend(self, 'dimension', lambda dimension: dimension == 'age') else True) + ): + raise PydanticCustomError( + 'allOf', + 'AudienceCharacteristic violates a constraint its schema states outside its properties (core/audience-characteristic.json, root conjunct 0) over dimension, max, min, range, value', + {'fields': 'dimension, max, min, range, value'}, + ) + return self + +class AudienceCharacteristicValue(AudienceCharacteristic): + """The anyOf/0 branch. + + One branch of core/audience-characteristic.json, stated as a shape: anyOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + range: None = None + value: Annotated[str | StrictFloat | StrictBool | Value, Field(description='Scalar or set value for the dimension.')] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'range'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) + ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> AudienceCharacteristicValue: + # Compiled from core/audience-characteristic.json anyOf/0: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + ((_c.descend(self, 'range', lambda range: _c.descend(range, 'max', lambda max: _c.within(max, ge=0) and _c.json_type(max, 'integer')) and _c.descend(range, 'min', lambda min: _c.within(min, ge=0) and _c.json_type(min, 'integer'))) and _c.descend(self, 'value', lambda value: [_c.within(value, ge=0) and _c.json_type(value, 'integer'), _c.every(value, lambda item: _c.within(item, ge=0) and _c.json_type(item, 'integer')) and _c.length(value, minimum=1) and _c.json_type(value, 'array') and _c.unique(value)].count(True) == 1)) if _c.descend(self, 'dimension', lambda dimension: dimension == 'age') else True) + ): + raise PydanticCustomError( + 'anyOf', + 'AudienceCharacteristicValue violates what core/audience-characteristic.json states for its anyOf/0 branch beyond the fields it requires and forbids', + ) + return self + + +class AudienceCharacteristicRange(AudienceCharacteristic): + """The anyOf/1 branch. + + One branch of core/audience-characteristic.json, stated as a shape: anyOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + value: None = None + range: Annotated[Range, Field(description='Inclusive numeric interval. For dimension `age`, values are completed years and MUST be non-negative integers, and min MUST be less than or equal to max. JSON Schema enforces the age value and bound types; implementations enforce the relational min <= max constraint.')] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'value'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> AudienceCharacteristicRange: + # Compiled from core/audience-characteristic.json anyOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + ((_c.descend(self, 'range', lambda range: _c.descend(range, 'max', lambda max: _c.within(max, ge=0) and _c.json_type(max, 'integer')) and _c.descend(range, 'min', lambda min: _c.within(min, ge=0) and _c.json_type(min, 'integer'))) and _c.descend(self, 'value', lambda value: [_c.within(value, ge=0) and _c.json_type(value, 'integer'), _c.every(value, lambda item: _c.within(item, ge=0) and _c.json_type(item, 'integer')) and _c.length(value, minimum=1) and _c.json_type(value, 'array') and _c.unique(value)].count(True) == 1)) if _c.descend(self, 'dimension', lambda dimension: dimension == 'age') else True) + ): + raise PydanticCustomError( + 'anyOf', + 'AudienceCharacteristicRange violates what core/audience-characteristic.json states for its anyOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +#: core/audience-characteristic.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate AudienceCharacteristic to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +AudienceCharacteristicBranches: TypeAlias = Annotated[ + AudienceCharacteristicValue | AudienceCharacteristicRange, + WrapValidator( + _c.name_the_combinator('anyOf', AudienceCharacteristic._branch_models(), AudienceCharacteristic._ROOT_REQUIRED) + ), +] + +# AudienceCharacteristic is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +AudienceCharacteristic.model_rebuild(force=True) diff --git a/src/adcp/types/domains/core/audience_evidence.py b/src/adcp/types/domains/core/audience_evidence.py index 826e8a853..e8429cf76 100644 --- a/src/adcp/types/domains/core/audience_evidence.py +++ b/src/adcp/types/domains/core/audience_evidence.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/audience_evidence.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Any, Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, RootModel, StrictFloat +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, RootModel, StrictFloat, model_validator from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type from . import audience_characteristic, brand_ref, date_range @@ -16,6 +16,12 @@ from .attestation_reference import AttestationReference +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Relationship(StrEnum): composition = 'composition' index = 'index' # type: ignore[assignment] @@ -474,3 +480,37 @@ class AudienceEvidence(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AudienceEvidence: + # Compiled from core/audience-evidence.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.descend(self, 'unit', lambda unit: unit == 'fraction') and _c.descend(self, 'value', lambda value: _c.within(value, le=1))) if (_c.descend(self, 'relationship', lambda relationship: relationship == 'composition') and _c.sent(self, 'relationship')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AudienceEvidence violates a constraint its schema states outside its properties (core/audience-evidence.json, root conjunct 0) over relationship, unit, value', + {'fields': 'relationship, unit, value'}, + ) + if not ( + (_c.descend(self, 'unit', lambda unit: unit == 'ratio') if (_c.descend(self, 'relationship', lambda relationship: relationship == 'index') and _c.sent(self, 'relationship')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AudienceEvidence violates a constraint its schema states outside its properties (core/audience-evidence.json, root conjunct 1) over relationship, unit', + {'fields': 'relationship, unit'}, + ) + if not ( + ((_c.descend(self, 'unit', lambda unit: unit == 'count') and _c.descend(self, 'value', lambda value: _c.json_type(value, 'integer'))) if (_c.descend(self, 'relationship', lambda relationship: relationship == 'reach_estimate') and _c.sent(self, 'relationship')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AudienceEvidence violates a constraint its schema states outside its properties (core/audience-evidence.json, root conjunct 2) over relationship, unit, value', + {'fields': 'relationship, unit, value'}, + ) + return self diff --git a/src/adcp/types/domains/core/audience_evidence_requirements.py b/src/adcp/types/domains/core/audience_evidence_requirements.py index fb55a146c..564ca6392 100644 --- a/src/adcp/types/domains/core/audience_evidence_requirements.py +++ b/src/adcp/types/domains/core/audience_evidence_requirements.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/audience_evidence_requirements.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type from . import attestation_issuer, brand_ref @@ -16,6 +16,12 @@ from .duration import Duration +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class RequirementMode(StrEnum): required = 'required' preferred = 'preferred' @@ -133,3 +139,21 @@ class AudienceEvidenceRequirements(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AudienceEvidenceRequirements: + # Compiled from core/audience-evidence-requirements.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'accepted_attestation_issuers') if (_c.descend(self, 'independent_attestation_required', lambda independent_attestation_required: independent_attestation_required == True) and _c.sent(self, 'independent_attestation_required')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AudienceEvidenceRequirements violates a constraint its schema states outside its properties (core/audience-evidence-requirements.json, root conjunct 0) over accepted_attestation_issuers, independent_attestation_required', + {'fields': 'accepted_attestation_issuers, independent_attestation_required'}, + ) + return self diff --git a/src/adcp/types/domains/core/audience_member.py b/src/adcp/types/domains/core/audience_member.py index 022ae6742..40f73be1a 100644 --- a/src/adcp/types/domains/core/audience_member.py +++ b/src/adcp/types/domains/core/audience_member.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: core/audience_member.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, model_validator @@ -13,6 +13,9 @@ from . import ext as ext_1 +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -56,19 +59,33 @@ class AudienceMember(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'hashed_email', 'hashed_phone', 'uids'}) @model_validator(mode='after') def _require_schema_required_group(self) -> AudienceMember: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('hashed_email',), ('hashed_phone',), ('uids',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'AudienceMember requires at least one of these field groups: hashed_email | hashed_phone | uids', - ) + # Compiled from core/audience-member.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # hashed_email + ( + _c.sent(self, 'hashed_email') + ) + # hashed_phone + or ( + _c.sent(self, 'hashed_phone') + ) + # uids + or ( + _c.sent(self, 'uids') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'AudienceMember requires at least one of these field groups: hashed_email | hashed_phone | uids', + ) + return self diff --git a/src/adcp/types/domains/core/bidding_policy.py b/src/adcp/types/domains/core/bidding_policy.py index d6d448201..3e0c4ed3c 100644 --- a/src/adcp/types/domains/core/bidding_policy.py +++ b/src/adcp/types/domains/core/bidding_policy.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/bidding_policy.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class Strength(StrEnum): @@ -95,3 +101,28 @@ class BiddingPolicy(AdCPBaseModel): description='Dimensionless return-on-ad-spend control bound to the same scope-specific primary goal rules as cost_per. The bound goal must be value-bearing; a fixed media-buy default requires a value-bearing primary goal on every inheriting package. Every referenced value-bearing event source MUST declare value_currencies containing the media-buy currency. The seller validates this at buy creation; each buy consumes only exact-currency records, while other declared currencies remain available to other buys. Sellers MUST NOT perform currency conversion.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> BiddingPolicy: + # Compiled from core/bidding-policy.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not ((_c.sent(self, 'automatic') and _c.sent(self, 'bid_amount')) or (_c.sent(self, 'automatic') and _c.sent(self, 'max_bid')) or (_c.sent(self, 'automatic') and _c.sent(self, 'cost_per')) or (_c.sent(self, 'automatic') and _c.sent(self, 'roas')) or (_c.sent(self, 'bid_amount') and _c.sent(self, 'max_bid')) or (_c.sent(self, 'bid_amount') and _c.sent(self, 'cost_per')) or (_c.sent(self, 'bid_amount') and _c.sent(self, 'roas')) or (_c.sent(self, 'cost_per') and _c.sent(self, 'roas'))) + ): + raise PydanticCustomError( + 'allOf', + 'BiddingPolicy violates a constraint its schema states outside its properties (core/bidding-policy.json, root conjunct 0) over automatic, bid_amount, cost_per, max_bid, roas', + {'fields': 'automatic, bid_amount, cost_per, max_bid, roas'}, + ) + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'BiddingPolicy violates a constraint its schema states outside its properties (core/bidding-policy.json, root conjunct 1)', + ) + return self diff --git a/src/adcp/types/domains/core/bidding_policy_capability.py b/src/adcp/types/domains/core/bidding_policy_capability.py index 02de476bf..446d21067 100644 --- a/src/adcp/types/domains/core/bidding_policy_capability.py +++ b/src/adcp/types/domains/core/bidding_policy_capability.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/bidding_policy_capability.json -# timestamp: 2026-08-17T23:02:13+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class Mode(StrEnum): @@ -115,3 +121,20 @@ class BiddingPolicyCapability(AdCPBaseModel): description='Policies accepted as package-authored policies, including explicit automatic overrides.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> BiddingPolicyCapability: + # Compiled from core/bidding-policy-capability.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'BiddingPolicyCapability violates a constraint its schema states outside its properties (core/bidding-policy-capability.json, root conjunct 0)', + ) + return self diff --git a/src/adcp/types/domains/core/budget_range.py b/src/adcp/types/domains/core/budget_range.py index a7580e938..882777492 100644 --- a/src/adcp/types/domains/core/budget_range.py +++ b/src/adcp/types/domains/core/budget_range.py @@ -1,15 +1,18 @@ # generated by datamodel-codegen: # filename: core/budget_range.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, StrictFloat, model_validator +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -20,19 +23,29 @@ class BudgetRange(AdCPBaseModel): min: Annotated[StrictFloat | None, Field(ge=0.0)] = None max: Annotated[StrictFloat | None, Field(ge=0.0)] = None currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'max', 'min'}) @model_validator(mode='after') def _require_schema_required_group(self) -> BudgetRange: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('min',), ('max',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'BudgetRange requires at least one of these field groups: min | max', - ) + # Compiled from core/budget-range.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # min + ( + _c.sent(self, 'min') + ) + # max + or ( + _c.sent(self, 'max') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'BudgetRange requires at least one of these field groups: min | max', + ) + return self diff --git a/src/adcp/types/domains/core/canonical_audience_evidence.py b/src/adcp/types/domains/core/canonical_audience_evidence.py index 3b07cb61a..3d2603df2 100644 --- a/src/adcp/types/domains/core/canonical_audience_evidence.py +++ b/src/adcp/types/domains/core/canonical_audience_evidence.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/canonical_audience_evidence.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,14 +8,20 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type from . import audience_characteristic, brand_key, date_range from . import ext as ext_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Relationship(StrEnum): composition = 'composition' index = 'index' # type: ignore[assignment] @@ -80,3 +86,37 @@ class CanonicalAudienceEvidence(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalAudienceEvidence: + # Compiled from core/canonical-audience-evidence.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.descend(self, 'unit', lambda unit: unit == 'fraction') and _c.descend(self, 'value', lambda value: _c.within(value, le=1))) if (_c.descend(self, 'relationship', lambda relationship: relationship == 'composition') and _c.sent(self, 'relationship')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalAudienceEvidence violates a constraint its schema states outside its properties (core/canonical-audience-evidence.json, root conjunct 0) over relationship, unit, value', + {'fields': 'relationship, unit, value'}, + ) + if not ( + (_c.descend(self, 'unit', lambda unit: unit == 'ratio') if (_c.descend(self, 'relationship', lambda relationship: relationship == 'index') and _c.sent(self, 'relationship')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalAudienceEvidence violates a constraint its schema states outside its properties (core/canonical-audience-evidence.json, root conjunct 1) over relationship, unit', + {'fields': 'relationship, unit'}, + ) + if not ( + ((_c.descend(self, 'unit', lambda unit: unit == 'count') and _c.descend(self, 'value', lambda value: _c.json_type(value, 'integer'))) if (_c.descend(self, 'relationship', lambda relationship: relationship == 'reach_estimate') and _c.sent(self, 'relationship')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalAudienceEvidence violates a constraint its schema states outside its properties (core/canonical-audience-evidence.json, root conjunct 2) over relationship, unit, value', + {'fields': 'relationship, unit, value'}, + ) + return self diff --git a/src/adcp/types/domains/core/canonical_format_option.py b/src/adcp/types/domains/core/canonical_format_option.py index 85983d6f1..42fe6a977 100644 --- a/src/adcp/types/domains/core/canonical_format_option.py +++ b/src/adcp/types/domains/core/canonical_format_option.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/canonical_format_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,13 +8,19 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from pydantic import AnyUrl, ConfigDict, Field, StrictBool, model_validator from ..enums import channels from . import creative_locale_policy, macro_resolution_capability, platform_extension_ref from . import tracker_execution_contract as tracker_execution_contract_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class SellerPreference(StrEnum): preferred = 'preferred' accepted = 'accepted' @@ -77,3 +83,37 @@ class CanonicalFormatOption(AdCPBaseModel): params: dict[str, Any] format_shape: Annotated[str | None, Field(min_length=1)] = None format_schema: platform_extension_ref.PlatformExtensionReference | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatOption: + # Compiled from core/canonical-format-option.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'format_option_id') if _c.sent(self, 'tracker_execution_contract') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatOption violates a constraint its schema states outside its properties (core/canonical-format-option.json, root conjunct 0) over format_option_id, tracker_execution_contract', + {'fields': 'format_option_id, tracker_execution_contract'}, + ) + if not ( + (_c.sent(self, 'format_option_id') if _c.sent(self, 'publisher_domain') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatOption violates a constraint its schema states outside its properties (core/canonical-format-option.json, root conjunct 1) over format_option_id, publisher_domain', + {'fields': 'format_option_id, publisher_domain'}, + ) + if not ( + ((_c.sent(self, 'format_shape') and _c.sent(self, 'format_schema') and _c.sent(self, 'canonical_formats_only')) if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'custom') and _c.sent(self, 'format_kind')) else not (_c.sent(self, 'format_shape') or _c.sent(self, 'format_schema'))) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatOption violates a constraint its schema states outside its properties (core/canonical-format-option.json, root conjunct 2) over canonical_formats_only, format_kind, format_schema, format_shape', + {'fields': 'canonical_formats_only, format_kind, format_schema, format_shape'}, + ) + return self diff --git a/src/adcp/types/domains/core/canonical_pricing_option.py b/src/adcp/types/domains/core/canonical_pricing_option.py index 09fed7ff6..d76efc9b1 100644 --- a/src/adcp/types/domains/core/canonical_pricing_option.py +++ b/src/adcp/types/domains/core/canonical_pricing_option.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/canonical_pricing_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..enums import adjustment_kind from ..enums import event_type as event_type_1 @@ -16,6 +16,12 @@ from ..pricing_options import price_guidance as price_guidance_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class PricingModel(StrEnum): cpm = 'cpm' vcpm = 'vcpm' @@ -48,3 +54,53 @@ class CanonicalPricingOption(AdCPBaseModel): event_source_id: Annotated[str | None, Field(min_length=1)] = None commission_rate: Annotated[StrictFloat | None, Field(gt=0.0, le=1.0)] = None commission_basis_description: Annotated[str | None, Field(max_length=1000, min_length=1)] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalPricingOption: + # Compiled from core/canonical-pricing-option.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'fixed_price') and _c.sent(self, 'floor_price')) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalPricingOption violates a constraint its schema states outside its properties (core/canonical-pricing-option.json, root conjunct 0) over fixed_price, floor_price', + {'fields': 'fixed_price, floor_price'}, + ) + if not ( + ((_c.sent(self, 'event_type') and _c.sent(self, 'fixed_price')) if (_c.descend(self, 'pricing_model', lambda pricing_model: pricing_model == 'cpa') and _c.sent(self, 'pricing_model')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalPricingOption violates a constraint its schema states outside its properties (core/canonical-pricing-option.json, root conjunct 1) over event_type, fixed_price, pricing_model', + {'fields': 'event_type, fixed_price, pricing_model'}, + ) + if not ( + ((_c.sent(self, 'event_type') and _c.sent(self, 'event_source_id') and _c.sent(self, 'commission_rate') and _c.sent(self, 'commission_basis_description')) if (_c.descend(self, 'pricing_model', lambda pricing_model: pricing_model == 'revenue_share') and _c.sent(self, 'pricing_model')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalPricingOption violates a constraint its schema states outside its properties (core/canonical-pricing-option.json, root conjunct 2) over commission_basis_description, commission_rate, event_source_id, event_type, pricing_model', + {'fields': 'commission_basis_description, commission_rate, event_source_id, event_type, pricing_model'}, + ) + if not ( + (_c.sent(self, 'parameters') if (_c.descend(self, 'pricing_model', lambda pricing_model: pricing_model in ['cpv', 'cpp', 'time']) and _c.sent(self, 'pricing_model')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalPricingOption violates a constraint its schema states outside its properties (core/canonical-pricing-option.json, root conjunct 3) over parameters, pricing_model', + {'fields': 'parameters, pricing_model'}, + ) + if not ( + (_c.sent(self, 'custom_event_name') if (_c.descend(self, 'event_type', lambda event_type: event_type == 'custom') and _c.sent(self, 'event_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalPricingOption violates a constraint its schema states outside its properties (core/canonical-pricing-option.json, root conjunct 4) over custom_event_name, event_type', + {'fields': 'custom_event_name, event_type'}, + ) + return self diff --git a/src/adcp/types/domains/core/canonical_product.py b/src/adcp/types/domains/core/canonical_product.py index 68373692c..af68e9eb9 100644 --- a/src/adcp/types/domains/core/canonical_product.py +++ b/src/adcp/types/domains/core/canonical_product.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_product.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator from ..enums import audio_distribution_type, catalog_type from ..enums import channels as channels_1 @@ -44,6 +43,13 @@ from . import targeting_overlay_support +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class PublisherDomain(ScalarStr): __slots__ = () _constraints = { @@ -288,3 +294,45 @@ class CanonicalProduct(AdCPBaseModel): acceptance_policy_profile_ids_1.AcceptancePolicyProfileIds | None ) = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalProduct: + # Compiled from core/canonical-product.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'targeting_resolution')) or _c.supplied(self, 'expires_at') + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalProduct violates a constraint its schema states outside its properties (core/canonical-product.json, root conjunct 0) over expires_at, targeting_resolution', + {'fields': 'expires_at, targeting_resolution'}, + ) + if not ( + ((_c.descend(self, 'collection_targeting_allowed', lambda collection_targeting_allowed: collection_targeting_allowed == True) and _c.sent(self, 'collection_targeting_allowed')) if (_c.descend(self, 'overlay_support', lambda overlay_support: _c.sent(overlay_support, 'collection_list')) and _c.sent(self, 'overlay_support')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalProduct violates a constraint its schema states outside its properties (core/canonical-product.json, root conjunct 1) over collection_list, collection_targeting_allowed, overlay_support', + {'fields': 'collection_list, collection_targeting_allowed, overlay_support'}, + ) + if not ( + (_c.sent(self, 'catalog_types') if (_c.descend(self, 'execution_requirements', lambda execution_requirements: _c.some(execution_requirements, lambda item: _c.descend(item, 'kind', lambda kind: kind == 'catalog') and _c.supplied(item, 'kind'))) and _c.sent(self, 'execution_requirements')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalProduct violates a constraint its schema states outside its properties (core/canonical-product.json, root conjunct 2) over catalog_types, execution_requirements, kind', + {'fields': 'catalog_types, execution_requirements, kind'}, + ) + if not ( + ((_c.descend(self, 'media_buy_support', lambda media_buy_support: not (_c.sent(media_buy_support, 'frequency_cap'))) and _c.descend(self, 'overlay_support', lambda overlay_support: not (_c.sent(overlay_support, 'frequency_cap') or _c.sent(overlay_support, 'frequency_cap_support')))) if (_c.descend(self, 'identity', lambda identity: _c.descend(identity, 'persistent_identifier', lambda persistent_identifier: persistent_identifier == False) and _c.sent(identity, 'persistent_identifier')) and _c.sent(self, 'identity')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalProduct violates a constraint its schema states outside its properties (core/canonical-product.json, root conjunct 3) over frequency_cap, frequency_cap_support, identity, media_buy_support, overlay_support, persistent_identifier', + {'fields': 'frequency_cap, frequency_cap_support, identity, media_buy_support, overlay_support, persistent_identifier'}, + ) + return self diff --git a/src/adcp/types/domains/core/canonical_proposal.py b/src/adcp/types/domains/core/canonical_proposal.py index 8e085820c..d2f402c61 100644 --- a/src/adcp/types/domains/core/canonical_proposal.py +++ b/src/adcp/types/domains/core/canonical_proposal.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/canonical_proposal.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..enums import proposal_status as proposal_status_1 from ..media_buy import commercial_terms as commercial_terms_1 @@ -16,6 +16,12 @@ from . import insertion_order as insertion_order_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ProposalKind(StrEnum): new_media_buy = 'new_media_buy' media_buy_update = 'media_buy_update' @@ -94,3 +100,45 @@ class CanonicalProposal(AdCPBaseModel): description="Aggregate forecasted delivery for the proposal. For outcome_target requests, points carry the goal's metric or event key in metrics; with cost_per, that is the goal volume planned under the commercial_terms_1.bidding policy, and currency equals cost_per.currency." ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalProposal: + # Compiled from core/canonical-proposal.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'parent_proposal_id') and _c.sent(self, 'media_buy_id') and _c.sent(self, 'base_media_buy_revision')) if (_c.descend(self, 'proposal_kind', lambda proposal_kind: proposal_kind in ['media_buy_update', 'media_buy_cancellation']) and _c.sent(self, 'proposal_kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalProposal violates a constraint its schema states outside its properties (core/canonical-proposal.json, root conjunct 0) over base_media_buy_revision, media_buy_id, parent_proposal_id, proposal_kind', + {'fields': 'base_media_buy_revision, media_buy_id, parent_proposal_id, proposal_kind'}, + ) + if not ( + ((_c.sent(self, 'media_buy_id') and _c.sent(self, 'accepted_at')) if (_c.descend(self, 'proposal_status', lambda proposal_status: proposal_status == 'accepted') and _c.sent(self, 'proposal_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalProposal violates a constraint its schema states outside its properties (core/canonical-proposal.json, root conjunct 1) over accepted_at, media_buy_id, proposal_status', + {'fields': 'accepted_at, media_buy_id, proposal_status'}, + ) + if not ( + (_c.sent(self, 'expires_at') if (_c.descend(self, 'proposal_status', lambda proposal_status: proposal_status == 'committed') and _c.sent(self, 'proposal_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalProposal violates a constraint its schema states outside its properties (core/canonical-proposal.json, root conjunct 2) over expires_at, proposal_status', + {'fields': 'expires_at, proposal_status'}, + ) + if not ( + (_c.descend(self, 'commercial_terms', lambda commercial_terms: _c.sent(commercial_terms, 'cancellation_terms')) if (_c.descend(self, 'proposal_kind', lambda proposal_kind: proposal_kind == 'media_buy_cancellation') and _c.sent(self, 'proposal_kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalProposal violates a constraint its schema states outside its properties (core/canonical-proposal.json, root conjunct 3) over cancellation_terms, commercial_terms, proposal_kind', + {'fields': 'cancellation_terms, commercial_terms, proposal_kind'}, + ) + return self diff --git a/src/adcp/types/domains/core/canvas_constraint.py b/src/adcp/types/domains/core/canvas_constraint.py index 27dd15603..f8cf6182b 100644 --- a/src/adcp/types/domains/core/canvas_constraint.py +++ b/src/adcp/types/domains/core/canvas_constraint.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/canvas_constraint.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class Constraint(StrEnum): @@ -42,3 +48,21 @@ class CanvasConstraint(AdCPBaseModel): state_id: Annotated[str | None, Field(pattern='^[a-z][a-z0-9_]*$')] = None breakpoint_id: Annotated[str | None, Field(pattern='^[a-z][a-z0-9_]*$')] = None region: Region + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanvasConstraint: + # Compiled from core/canvas-constraint.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'breakpoint_id')) or _c.supplied(self, 'state_id') + ): + raise PydanticCustomError( + 'allOf', + 'CanvasConstraint violates a constraint its schema states outside its properties (core/canvas-constraint.json, root conjunct 0) over breakpoint_id, state_id', + {'fields': 'breakpoint_id, state_id'}, + ) + return self diff --git a/src/adcp/types/domains/core/catalog_field_mapping.py b/src/adcp/types/domains/core/catalog_field_mapping.py index e02382404..1ac290855 100644 --- a/src/adcp/types/domains/core/catalog_field_mapping.py +++ b/src/adcp/types/domains/core/catalog_field_mapping.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/catalog_field_mapping.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator from . import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Transform(StrEnum): date = 'date' divide = 'divide' @@ -86,3 +92,29 @@ class CatalogFieldMapping(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CatalogFieldMapping: + # Compiled from core/catalog-field-mapping.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'feed_field') and _c.sent(self, 'value')) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogFieldMapping violates a constraint its schema states outside its properties (core/catalog-field-mapping.json, root conjunct 0) over feed_field, value', + {'fields': 'feed_field, value'}, + ) + if not ( + not (_c.sent(self, 'catalog_field') and _c.sent(self, 'asset_group_id')) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogFieldMapping violates a constraint its schema states outside its properties (core/catalog-field-mapping.json, root conjunct 1) over asset_group_id, catalog_field', + {'fields': 'asset_group_id, catalog_field'}, + ) + return self diff --git a/src/adcp/types/domains/core/catalog_item_availability_state.py b/src/adcp/types/domains/core/catalog_item_availability_state.py index 48a4ec100..7be7aabce 100644 --- a/src/adcp/types/domains/core/catalog_item_availability_state.py +++ b/src/adcp/types/domains/core/catalog_item_availability_state.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/catalog_item_availability_state.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import catalog_item_availability_error from . import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Status(StrEnum): found = 'found' failed = 'failed' @@ -72,3 +78,37 @@ class CatalogItemAvailabilityState(AdCPBaseModel): Field(min_length=1), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CatalogItemAvailabilityState: + # Compiled from core/catalog-item-availability-state.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((not (_c.sent(self, 'errors')) and (_c.sent(self, 'availability') and _c.sent(self, 'overlay_revision') and _c.sent(self, 'updated_at'))) if (_c.descend(self, 'status', lambda status: status == 'found') and _c.sent(self, 'status')) else (not (_c.sent(self, 'availability') or _c.sent(self, 'overlay_revision') or _c.sent(self, 'expires_at') or _c.sent(self, 'updated_at')) and _c.keys(self, lambda name: name in ['request_index', 'catalog_id', 'catalog_generation', 'item_id', 'status', 'errors']) and _c.sent(self, 'errors'))) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityState violates a constraint its schema states outside its properties (core/catalog-item-availability-state.json, root conjunct 0) over availability, errors, expires_at, overlay_revision, status, updated_at', + {'fields': 'availability, errors, expires_at, overlay_revision, status, updated_at'}, + ) + if not ( + (_c.descend(self, 'availability', lambda availability: availability == 'suppressed') if _c.sent(self, 'expires_at') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityState violates a constraint its schema states outside its properties (core/catalog-item-availability-state.json, root conjunct 1) over availability, expires_at', + {'fields': 'availability, expires_at'}, + ) + if not ( + (_c.descend(self, 'errors', lambda errors: _c.every(errors, lambda item: _c.only(item, ['code', 'message', 'recovery']) and (_c.descend(item, 'code', lambda code: code == 'REFERENCE_NOT_FOUND' and _c.json_type(code, 'string')) and _c.descend(item, 'message', lambda message: message == 'Catalog item not found' and _c.json_type(message, 'string')) and _c.descend(item, 'recovery', lambda recovery: recovery == 'correctable' and _c.json_type(recovery, 'string'))) and (_c.sent(item, 'code') and _c.sent(item, 'message') and _c.sent(item, 'recovery')) and _c.json_type(item, 'object')) and _c.length(errors, maximum=1)) if (_c.descend(self, 'errors', lambda errors: _c.some(errors, lambda item: _c.descend(item, 'code', lambda code: code == 'REFERENCE_NOT_FOUND') and _c.supplied(item, 'code'))) and _c.sent(self, 'errors')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityState violates a constraint its schema states outside its properties (core/catalog-item-availability-state.json, root conjunct 2) over code, errors, message, recovery', + {'fields': 'code, errors, message, recovery'}, + ) + return self diff --git a/src/adcp/types/domains/core/catalog_item_availability_update.py b/src/adcp/types/domains/core/catalog_item_availability_update.py index 0e4d5164f..e9c810000 100644 --- a/src/adcp/types/domains/core/catalog_item_availability_update.py +++ b/src/adcp/types/domains/core/catalog_item_availability_update.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/catalog_item_availability_update.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Action(StrEnum): suppress = 'suppress' restore = 'restore' @@ -93,3 +99,37 @@ class CatalogItemAvailabilityUpdate(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CatalogItemAvailabilityUpdate: + # Compiled from core/catalog-item-availability-update.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'reason_detail') if (_c.descend(self, 'reason', lambda reason: reason == 'other') and _c.sent(self, 'reason')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityUpdate violates a constraint its schema states outside its properties (core/catalog-item-availability-update.json, root conjunct 0) over reason, reason_detail', + {'fields': 'reason, reason_detail'}, + ) + if not ( + (_c.descend(self, 'reason', lambda reason: reason in ['out_of_stock', 'content_unavailable', 'promotion_end', 'time_window_expired', 'buyer_request', 'other']) if (_c.descend(self, 'action', lambda action: action == 'suppress') and _c.sent(self, 'action')) else _c.descend(self, 'reason', lambda reason: reason in ['back_in_stock', 'content_available', 'promotion_start', 'time_window_started', 'buyer_request', 'other'])) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityUpdate violates a constraint its schema states outside its properties (core/catalog-item-availability-update.json, root conjunct 1) over action, reason', + {'fields': 'action, reason'}, + ) + if not ( + (_c.descend(self, 'action', lambda action: action == 'suppress') if _c.sent(self, 'expires_at') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityUpdate violates a constraint its schema states outside its properties (core/catalog-item-availability-update.json, root conjunct 2) over action, expires_at', + {'fields': 'action, expires_at'}, + ) + return self diff --git a/src/adcp/types/domains/core/catalog_item_availability_update_result.py b/src/adcp/types/domains/core/catalog_item_availability_update_result.py index 675646cfc..c066310d3 100644 --- a/src/adcp/types/domains/core/catalog_item_availability_update_result.py +++ b/src/adcp/types/domains/core/catalog_item_availability_update_result.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/catalog_item_availability_update_result.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import catalog_item_availability_error from . import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Action(StrEnum): suppress = 'suppress' restore = 'restore' @@ -83,3 +89,61 @@ class CatalogItemAvailabilityUpdateResult(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CatalogItemAvailabilityUpdateResult: + # Compiled from core/catalog-item-availability-update-result.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((not (_c.sent(self, 'applied_at') or _c.sent(self, 'availability') or _c.sent(self, 'overlay_revision') or _c.sent(self, 'expires_at')) and _c.keys(self, lambda name: name in ['request_index', 'catalog_id', 'catalog_generation', 'item_id', 'action', 'status', 'errors']) and _c.sent(self, 'errors')) if (_c.descend(self, 'status', lambda status: status == 'failed') and _c.sent(self, 'status')) else (not (_c.sent(self, 'errors')) and (_c.sent(self, 'availability') and _c.sent(self, 'overlay_revision')))) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityUpdateResult violates a constraint its schema states outside its properties (core/catalog-item-availability-update-result.json, root conjunct 0) over applied_at, availability, errors, expires_at, overlay_revision, status', + {'fields': 'applied_at, availability, errors, expires_at, overlay_revision, status'}, + ) + if not ( + (_c.descend(self, 'errors', lambda errors: _c.every(errors, lambda item: _c.only(item, ['code', 'message', 'recovery']) and (_c.descend(item, 'code', lambda code: code == 'REFERENCE_NOT_FOUND' and _c.json_type(code, 'string')) and _c.descend(item, 'message', lambda message: message == 'Catalog item not found' and _c.json_type(message, 'string')) and _c.descend(item, 'recovery', lambda recovery: recovery == 'correctable' and _c.json_type(recovery, 'string'))) and (_c.sent(item, 'code') and _c.sent(item, 'message') and _c.sent(item, 'recovery')) and _c.json_type(item, 'object')) and _c.length(errors, maximum=1)) if (_c.descend(self, 'errors', lambda errors: _c.some(errors, lambda item: _c.descend(item, 'code', lambda code: code == 'REFERENCE_NOT_FOUND') and _c.supplied(item, 'code'))) and _c.sent(self, 'errors')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityUpdateResult violates a constraint its schema states outside its properties (core/catalog-item-availability-update-result.json, root conjunct 1) over code, errors, message, recovery', + {'fields': 'code, errors, message, recovery'}, + ) + if not ( + ((not (_c.sent(self, 'errors')) and _c.sent(self, 'applied_at')) if (_c.descend(self, 'status', lambda status: status == 'applied') and _c.sent(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityUpdateResult violates a constraint its schema states outside its properties (core/catalog-item-availability-update-result.json, root conjunct 2) over applied_at, errors, status', + {'fields': 'applied_at, errors, status'}, + ) + if not ( + (_c.descend(self, 'availability', lambda availability: availability == 'suppressed') if _c.sent(self, 'expires_at') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityUpdateResult violates a constraint its schema states outside its properties (core/catalog-item-availability-update-result.json, root conjunct 3) over availability, expires_at', + {'fields': 'availability, expires_at'}, + ) + if not ( + (_c.descend(self, 'availability', lambda availability: availability == 'suppressed') if ((_c.descend(self, 'action', lambda action: action == 'suppress') and _c.descend(self, 'status', lambda status: status in ['applied', 'unchanged'])) and (_c.sent(self, 'action') and _c.sent(self, 'status'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityUpdateResult violates a constraint its schema states outside its properties (core/catalog-item-availability-update-result.json, root conjunct 4) over action, availability, status', + {'fields': 'action, availability, status'}, + ) + if not ( + (_c.descend(self, 'availability', lambda availability: availability == 'active') if ((_c.descend(self, 'action', lambda action: action == 'restore') and _c.descend(self, 'status', lambda status: status in ['applied', 'unchanged'])) and (_c.sent(self, 'action') and _c.sent(self, 'status'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CatalogItemAvailabilityUpdateResult violates a constraint its schema states outside its properties (core/catalog-item-availability-update-result.json, root conjunct 5) over action, availability, status', + {'fields': 'action, availability, status'}, + ) + return self diff --git a/src/adcp/types/domains/core/catchment.py b/src/adcp/types/domains/core/catchment.py index 533729c7b..fd9072999 100644 --- a/src/adcp/types/domains/core/catchment.py +++ b/src/adcp/types/domains/core/catchment.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/catchment.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum -from typing import Annotated, Any +from typing import Annotated, Any, ClassVar, TypeAlias from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat, model_validator +from pydantic import ConfigDict, Field, StrictFloat, WrapValidator, model_validator from ..enums import distance_unit from ..enums import transport_mode as transport_mode_1 @@ -16,9 +16,15 @@ from . import ext as ext_1 +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError +from adcp.types._branch_dispatch import BranchDispatchMixin + + class Type(StrEnum): Polygon = 'Polygon' MultiPolygon = 'MultiPolygon' @@ -53,7 +59,26 @@ class Radius(AdCPBaseModel): unit: Annotated[distance_unit.DistanceUnit, Field(description='Distance unit.')] -class Catchment(AdCPBaseModel): +class Catchment(BranchDispatchMixin, AdCPBaseModel): + """What every branch of core/catchment.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``CatchmentTransportModeTravelTime`` + ``CatchmentRadius`` + ``CatchmentGeometry`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + CatchmentBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + model_config = ConfigDict( extra='allow', ) @@ -94,19 +119,170 @@ class Catchment(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'geometry', 'radius', 'transport_mode', 'travel_time'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'catchment_id'}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + CatchmentTransportModeTravelTime, + CatchmentRadius, + CatchmentGeometry, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' @model_validator(mode='after') def _require_schema_required_group(self) -> Catchment: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('travel_time', 'transport_mode'), ('radius',), ('geometry',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'oneOf', - 'Catchment requires at least one of these field groups: travel_time+transport_mode | radius | geometry', + # Compiled from core/catchment.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # travel_time+transport_mode + ( + not (_c.sent(self, 'radius') or _c.sent(self, 'geometry')) + and (_c.sent(self, 'travel_time') and _c.sent(self, 'transport_mode')) + ) + # radius + or ( + not (_c.sent(self, 'travel_time') or _c.sent(self, 'geometry')) + and _c.sent(self, 'radius') + ) + # geometry + or ( + not (_c.sent(self, 'travel_time') or _c.sent(self, 'radius')) + and _c.sent(self, 'geometry') + ) + ): + raise PydanticCustomError( + 'oneOf', + 'Catchment requires at least one of these field groups: travel_time+transport_mode | radius | geometry', + ) + if [(not (_c.sent(self, 'radius') or _c.sent(self, 'geometry')) and (_c.sent(self, 'travel_time') and _c.sent(self, 'transport_mode'))), (not (_c.sent(self, 'travel_time') or _c.sent(self, 'geometry')) and _c.sent(self, 'radius')), (not (_c.sent(self, 'travel_time') or _c.sent(self, 'radius')) and _c.sent(self, 'geometry'))].count(True) != 1: + raise PydanticCustomError( + 'oneOf', + 'Catchment satisfies more than one of these mutually exclusive field groups: travel_time+transport_mode | radius | geometry', + ) + return self + +class CatchmentTransportModeTravelTime(Catchment): + """The oneOf/0 branch. + + One branch of core/catchment.json, stated as a shape: oneOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + geometry: None = None + radius: None = None + transport_mode: Annotated[transport_mode_1.TransportMode, Field(description='Transportation mode for isochrone calculation. Required when travel_time is provided.')] + travel_time: Annotated[TravelTime, Field(description='Travel time limit for isochrone calculation. The platform resolves this to a geographic boundary based on actual transportation networks, accounting for road connectivity, transit schedules, and terrain.')] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'geometry', 'radius'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) + ) + + +class CatchmentRadius(Catchment): + """The oneOf/1 branch. + + One branch of core/catchment.json, stated as a shape: oneOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + geometry: None = None + travel_time: None = None + radius: Annotated[Radius, Field(description="Simple radius from the store location. The platform draws a circle of this distance around the store's coordinates.")] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'geometry', 'travel_time'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) + ) + + +class CatchmentGeometry(Catchment): + """The oneOf/2 branch. + + One branch of core/catchment.json, stated as a shape: oneOf/2 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + radius: None = None + travel_time: None = None + geometry: Annotated[Geometry, Field(description='Pre-computed GeoJSON geometry defining the catchment boundary. Use this when the buyer has already calculated isochrones (via TravelTime, Mapbox, etc.) or has custom trade area boundaries. Supports Polygon and MultiPolygon types.')] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'radius', 'travel_time'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) ) + + +#: core/catchment.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate Catchment to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +CatchmentBranches: TypeAlias = Annotated[ + CatchmentTransportModeTravelTime | CatchmentRadius | CatchmentGeometry, + WrapValidator( + _c.name_the_combinator('oneOf', Catchment._branch_models(), Catchment._ROOT_REQUIRED) + ), +] + +# Catchment is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +Catchment.model_rebuild(force=True) diff --git a/src/adcp/types/domains/core/collection_distribution.py b/src/adcp/types/domains/core/collection_distribution.py index b89aa5e22..c98edec00 100644 --- a/src/adcp/types/domains/core/collection_distribution.py +++ b/src/adcp/types/domains/core/collection_distribution.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: core/collection_distribution.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, model_validator @@ -13,6 +13,9 @@ from . import property_id +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -51,19 +54,29 @@ class CollectionDistribution(AdCPBaseModel): min_length=1, ), ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'identifiers', 'property_ids'}) @model_validator(mode='after') def _require_schema_required_group(self) -> CollectionDistribution: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('property_ids',), ('identifiers',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'CollectionDistribution requires at least one of these field groups: property_ids | identifiers', - ) + # Compiled from core/collection-distribution.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # property_ids + ( + _c.sent(self, 'property_ids') + ) + # identifiers + or ( + _c.sent(self, 'identifiers') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'CollectionDistribution requires at least one of these field groups: property_ids | identifiers', + ) + return self diff --git a/src/adcp/types/domains/core/creative_approval_scope.py b/src/adcp/types/domains/core/creative_approval_scope.py index ed2909a68..203b4ff5a 100644 --- a/src/adcp/types/domains/core/creative_approval_scope.py +++ b/src/adcp/types/domains/core/creative_approval_scope.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: core/creative_approval_scope.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import creative_approval_status from . import indicator_scope +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ScopedCreativeApproval(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -25,3 +31,29 @@ class ScopedCreativeApproval(AdCPBaseModel): rejection_reason: Annotated[ str | None, Field(description='Human-readable explanation when this scope is rejected.') ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ScopedCreativeApproval: + # Compiled from core/creative-approval-scope.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'rejection_reason') if (_c.descend(self, 'approval_status', lambda approval_status: approval_status == 'rejected') and _c.sent(self, 'approval_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ScopedCreativeApproval violates a constraint its schema states outside its properties (core/creative-approval-scope.json, root conjunct 0) over approval_status, rejection_reason', + {'fields': 'approval_status, rejection_reason'}, + ) + if not ( + (not (_c.sent(self, 'rejection_reason')) if (_c.descend(self, 'approval_status', lambda approval_status: approval_status in ['approved', 'pending_review']) and _c.sent(self, 'approval_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ScopedCreativeApproval violates a constraint its schema states outside its properties (core/creative-approval-scope.json, root conjunct 1) over approval_status, rejection_reason', + {'fields': 'approval_status, rejection_reason'}, + ) + return self diff --git a/src/adcp/types/domains/core/creative_asset.py b/src/adcp/types/domains/core/creative_asset.py index a198d3825..83bb8f451 100644 --- a/src/adcp/types/domains/core/creative_asset.py +++ b/src/adcp/types/domains/core/creative_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_asset.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any, ClassVar, TypeAlias from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, RootModel, StrictFloat, StringConstraints, model_validator +from pydantic import ConfigDict, Field, RootModel, StrictFloat, StringConstraints, WrapValidator, model_validator from ..enums import creative_status from . import creative_assets @@ -20,9 +20,15 @@ from .assets import asset_union +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError +from adcp.types._branch_dispatch import BranchDispatchMixin + + class Input(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -41,7 +47,25 @@ class Assets(RootModel[list[asset_union.AssetVariant]]): root: Annotated[list[asset_union.AssetVariant], Field(min_length=1)] -class CreativeAsset(AdCPBaseModel): +class CreativeAsset(BranchDispatchMixin, AdCPBaseModel): + """What every branch of core/creative-asset.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``CreativeAssetV1CreativeNamedFormatReference`` + ``CreativeAssetV2CreativeCanonicalFormatKind`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + CreativeAssetBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + model_config = ConfigDict( extra='allow', ) @@ -146,19 +170,176 @@ class CreativeAsset(AdCPBaseModel): min_length=1, ), ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'format_id', 'format_kind'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'assets', 'creative_id', 'name'}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + CreativeAssetV1CreativeNamedFormatReference, + CreativeAssetV2CreativeCanonicalFormatKind, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' @model_validator(mode='after') def _require_schema_required_group(self) -> CreativeAsset: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('format_id',), ('format_kind',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'oneOf', - 'CreativeAsset requires at least one of these field groups: format_id | format_kind', + # Compiled from core/creative-asset.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # v1 creative (named-format reference) + ( + not (_c.sent(self, 'format_kind')) and _c.sent(self, 'format_id') + ) + # v2 creative (canonical format kind) + or ( + not (_c.sent(self, 'format_id')) and _c.sent(self, 'format_kind') + ) + ): + raise PydanticCustomError( + 'oneOf', + 'CreativeAsset requires at least one of these field groups: v1 creative (named-format reference) | v2 creative (canonical format kind)', + ) + if [(not (_c.sent(self, 'format_kind')) and _c.sent(self, 'format_id')), (not (_c.sent(self, 'format_id')) and _c.sent(self, 'format_kind'))].count(True) != 1: + raise PydanticCustomError( + 'oneOf', + 'CreativeAsset satisfies more than one of these mutually exclusive field groups: v1 creative (named-format reference) | v2 creative (canonical format kind)', + ) + if not ( + not (_c.supplied(self, 'capability_id') or _c.supplied(self, 'capability_ref')) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeAsset violates a constraint its schema states outside its properties (core/creative-asset.json, root conjunct 0) over capability_id, capability_ref', + {'fields': 'capability_id, capability_ref'}, + ) + if not ( + (_c.sent(self, 'component_assets') if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'coordinated_placements') and _c.sent(self, 'format_kind')) else not (_c.sent(self, 'component_assets'))) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeAsset violates a constraint its schema states outside its properties (core/creative-asset.json, root conjunct 1) over component_assets, format_kind', + {'fields': 'component_assets, format_kind'}, + ) + return self + +class CreativeAssetV1CreativeNamedFormatReference(CreativeAsset): + """v1 creative (named-format reference). + + One branch of core/creative-asset.json, stated as a shape: oneOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + format_kind: None = None + format_id: Annotated[format_id_1.FormatReferenceStructuredObject, Field(deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format path retained for older 3.x peers. New creative assets use `format_kind` and optional `format_option_ref`.')] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'format_kind'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) + ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreativeAssetV1CreativeNamedFormatReference: + # Compiled from core/creative-asset.json oneOf/0: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + not (_c.supplied(self, 'capability_id') or _c.supplied(self, 'capability_ref')) and (_c.sent(self, 'component_assets') if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'coordinated_placements') and _c.sent(self, 'format_kind')) else not (_c.sent(self, 'component_assets'))) + ): + raise PydanticCustomError( + 'oneOf', + 'CreativeAssetV1CreativeNamedFormatReference violates what core/creative-asset.json states for its oneOf/0 branch beyond the fields it requires and forbids', + ) + return self + + +class CreativeAssetV2CreativeCanonicalFormatKind(CreativeAsset): + """v2 creative (canonical format kind). + + One branch of core/creative-asset.json, stated as a shape: oneOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + format_id: None = None + format_kind: Annotated[str, Field(description='Canonical format name this creative targets (e.g., `image`, `video_hosted`). Mutually exclusive with deprecated `format_id`.')] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'format_id'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreativeAssetV2CreativeCanonicalFormatKind: + # Compiled from core/creative-asset.json oneOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + not (_c.supplied(self, 'capability_id') or _c.supplied(self, 'capability_ref')) and (_c.sent(self, 'component_assets') if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'coordinated_placements') and _c.sent(self, 'format_kind')) else not (_c.sent(self, 'component_assets'))) + ): + raise PydanticCustomError( + 'oneOf', + 'CreativeAssetV2CreativeCanonicalFormatKind violates what core/creative-asset.json states for its oneOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +#: core/creative-asset.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate CreativeAsset to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +CreativeAssetBranches: TypeAlias = Annotated[ + CreativeAssetV1CreativeNamedFormatReference | CreativeAssetV2CreativeCanonicalFormatKind, + WrapValidator( + _c.name_the_combinator('oneOf', CreativeAsset._branch_models(), CreativeAsset._ROOT_REQUIRED) + ), +] + +# CreativeAsset is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +CreativeAsset.model_rebuild(force=True) diff --git a/src/adcp/types/domains/core/creative_assignment.py b/src/adcp/types/domains/core/creative_assignment.py index c2b5857fe..f714864e8 100644 --- a/src/adcp/types/domains/core/creative_assignment.py +++ b/src/adcp/types/domains/core/creative_assignment.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/creative_assignment.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator from . import placement_ref +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class RotationMode(StrEnum): weighted = 'weighted' even = 'even' @@ -68,3 +74,37 @@ class CreativeAssignment(AdCPBaseModel): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreativeAssignment: + # Compiled from core/creative-assignment.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'sequence_position') if (_c.descend(self, 'rotation_mode', lambda rotation_mode: rotation_mode == 'sequential') and _c.sent(self, 'rotation_mode')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeAssignment violates a constraint its schema states outside its properties (core/creative-assignment.json, root conjunct 0) over rotation_mode, sequence_position', + {'fields': 'rotation_mode, sequence_position'}, + ) + if not ( + ((_c.descend(self, 'rotation_mode', lambda rotation_mode: rotation_mode == 'sequential') and _c.sent(self, 'rotation_mode')) if _c.sent(self, 'sequence_position') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeAssignment violates a constraint its schema states outside its properties (core/creative-assignment.json, root conjunct 1) over rotation_mode, sequence_position', + {'fields': 'rotation_mode, sequence_position'}, + ) + if not ( + (not (_c.sent(self, 'weight')) if (_c.descend(self, 'rotation_mode', lambda rotation_mode: rotation_mode in ['even', 'sequential', 'random']) and _c.sent(self, 'rotation_mode')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeAssignment violates a constraint its schema states outside its properties (core/creative-assignment.json, root conjunct 2) over rotation_mode, weight', + {'fields': 'rotation_mode, weight'}, + ) + return self diff --git a/src/adcp/types/domains/core/creative_manifest.py b/src/adcp/types/domains/core/creative_manifest.py index 24e57fb11..acbc5a107 100644 --- a/src/adcp/types/domains/core/creative_manifest.py +++ b/src/adcp/types/domains/core/creative_manifest.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_manifest.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated, Any +from typing import Annotated, Any, ClassVar, TypeAlias from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, RootModel, StringConstraints, model_validator +from pydantic import ConfigDict, Field, RootModel, StringConstraints, WrapValidator, model_validator from . import brand_ref, creative_assets from . import ext as ext_1 @@ -20,6 +20,9 @@ from .assets import asset_union +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -31,15 +34,41 @@ def _normalize_asset_models(value: Any) -> Any: return value +from adcp.types._readback import readback_clone, readback_normalizer + + +from adcp.types._split_root import shared_base + + +from adcp.types._branch_dispatch import BranchDispatchMixin + + class Assets(RootModel[list[asset_union.AssetVariant]]): root: Annotated[list[asset_union.AssetVariant], Field(min_length=1)] -class CreativeManifest(AdCPBaseModel): +class CreativeManifest(BranchDispatchMixin, AdCPBaseModel): + """What every branch of core/creative-manifest.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``CreativeManifestNamedFormatManifest`` + ``CreativeManifestCanonicalFormatManifest`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + CreativeManifestBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + model_config = ConfigDict( extra='allow', ) - @model_validator(mode='before') @classmethod def _coerce_standalone_assets(cls, data: Any) -> Any: @@ -49,7 +78,6 @@ def _coerce_standalone_assets(cls, data: Any) -> Any: **data, 'assets': {key: _normalize_asset_models(value) for key, value in data['assets'].items()}, } - format_id: Annotated[ format_id_1.FormatReferenceStructuredObject | None, Field( @@ -112,19 +140,201 @@ def _coerce_standalone_assets(cls, data: Any) -> Any: ), ] = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'format_id', 'format_kind'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'assets'}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + CreativeManifestNamedFormatManifest, + CreativeManifestCanonicalFormatManifest, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' @model_validator(mode='after') def _require_schema_required_group(self) -> CreativeManifest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('format_id',), ('format_kind',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'oneOf', - 'CreativeManifest requires at least one of these field groups: format_id | format_kind', + # Compiled from core/creative-manifest.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # Named-format manifest + ( + not (_c.sent(self, 'format_kind')) and _c.sent(self, 'format_id') + ) + # Canonical-format manifest + or ( + not (_c.sent(self, 'format_id')) and _c.sent(self, 'format_kind') + ) + ): + raise PydanticCustomError( + 'oneOf', + 'CreativeManifest requires at least one of these field groups: Named-format manifest | Canonical-format manifest', + ) + if [(not (_c.sent(self, 'format_kind')) and _c.sent(self, 'format_id')), (not (_c.sent(self, 'format_id')) and _c.sent(self, 'format_kind'))].count(True) != 1: + raise PydanticCustomError( + 'oneOf', + 'CreativeManifest satisfies more than one of these mutually exclusive field groups: Named-format manifest | Canonical-format manifest', + ) + if not ( + not (_c.supplied(self, 'capability_id') or _c.supplied(self, 'capability_ref')) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeManifest violates a constraint its schema states outside its properties (core/creative-manifest.json, root conjunct 0) over capability_id, capability_ref', + {'fields': 'capability_id, capability_ref'}, + ) + if not ( + (_c.sent(self, 'component_assets') if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'coordinated_placements') and _c.sent(self, 'format_kind')) else not (_c.sent(self, 'component_assets'))) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeManifest violates a constraint its schema states outside its properties (core/creative-manifest.json, root conjunct 1) over component_assets, format_kind', + {'fields': 'component_assets, format_kind'}, + ) + if not ( + (_c.descend(self, 'assets', lambda assets: _c.matching_keys(assets, '.*', lambda item: (_c.supplied(item, 'vast_version') if (_c.descend(item, 'asset_type', lambda asset_type: asset_type == 'vast') and _c.supplied(item, 'asset_type') and _c.json_type(item, 'object')) else True) and (_c.every(item, lambda item: (_c.supplied(item, 'vast_version') if (_c.descend(item, 'asset_type', lambda asset_type: asset_type == 'vast') and _c.supplied(item, 'asset_type') and _c.json_type(item, 'object')) else True)) if _c.json_type(item, 'array') else True))) if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'audio_vast') and _c.sent(self, 'format_kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeManifest violates a constraint its schema states outside its properties (core/creative-manifest.json, root conjunct 2) over asset_type, assets, format_kind, vast_version', + {'fields': 'asset_type, assets, format_kind, vast_version'}, + ) + return self + +class CreativeManifestNamedFormatManifest(CreativeManifest): + """Named-format manifest. + + One branch of core/creative-manifest.json, stated as a shape: oneOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + format_kind: None = None + format_id: Annotated[format_id_1.FormatReferenceStructuredObject, Field(deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format path retained for 3.x compatibility. New manifests use canonical `format_kind` and, when product routing requires it, `format_option_ref`. Mutually exclusive with format_kind.')] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'format_kind'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreativeManifestNamedFormatManifest: + # Compiled from core/creative-manifest.json oneOf/0: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + not (_c.supplied(self, 'capability_id') or _c.supplied(self, 'capability_ref')) and (_c.sent(self, 'component_assets') if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'coordinated_placements') and _c.sent(self, 'format_kind')) else not (_c.sent(self, 'component_assets'))) and (_c.descend(self, 'assets', lambda assets: _c.matching_keys(assets, '.*', lambda item: (_c.supplied(item, 'vast_version') if (_c.descend(item, 'asset_type', lambda asset_type: asset_type == 'vast') and _c.supplied(item, 'asset_type') and _c.json_type(item, 'object')) else True) and (_c.every(item, lambda item: (_c.supplied(item, 'vast_version') if (_c.descend(item, 'asset_type', lambda asset_type: asset_type == 'vast') and _c.supplied(item, 'asset_type') and _c.json_type(item, 'object')) else True)) if _c.json_type(item, 'array') else True))) if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'audio_vast') and _c.sent(self, 'format_kind')) else True) + ): + raise PydanticCustomError( + 'oneOf', + 'CreativeManifestNamedFormatManifest violates what core/creative-manifest.json states for its oneOf/0 branch beyond the fields it requires and forbids', + ) + return self + + +class CreativeManifestCanonicalFormatManifest(CreativeManifest): + """Canonical-format manifest. + + One branch of core/creative-manifest.json, stated as a shape: oneOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + format_id: None = None + format_kind: Annotated[str, Field(description="Canonical 3.2 path. The canonical format name this manifest targets (e.g., `image`, `video_hosted`, `audio_vast`, `seller_rendered_stateful_display`, `coordinated_placements`). Selects the contract against which the seller validates the manifest's assets. Mutually exclusive with deprecated `format_id`.")] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'format_id'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) + ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreativeManifestCanonicalFormatManifest: + # Compiled from core/creative-manifest.json oneOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + not (_c.supplied(self, 'capability_id') or _c.supplied(self, 'capability_ref')) and (_c.sent(self, 'component_assets') if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'coordinated_placements') and _c.sent(self, 'format_kind')) else not (_c.sent(self, 'component_assets'))) and (_c.descend(self, 'assets', lambda assets: _c.matching_keys(assets, '.*', lambda item: (_c.supplied(item, 'vast_version') if (_c.descend(item, 'asset_type', lambda asset_type: asset_type == 'vast') and _c.supplied(item, 'asset_type') and _c.json_type(item, 'object')) else True) and (_c.every(item, lambda item: (_c.supplied(item, 'vast_version') if (_c.descend(item, 'asset_type', lambda asset_type: asset_type == 'vast') and _c.supplied(item, 'asset_type') and _c.json_type(item, 'object')) else True)) if _c.json_type(item, 'array') else True))) if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'audio_vast') and _c.sent(self, 'format_kind')) else True) + ): + raise PydanticCustomError( + 'oneOf', + 'CreativeManifestCanonicalFormatManifest violates what core/creative-manifest.json states for its oneOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +#: core/creative-manifest.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate CreativeManifest to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +CreativeManifestBranches: TypeAlias = Annotated[ + CreativeManifestNamedFormatManifest | CreativeManifestCanonicalFormatManifest, + WrapValidator( + _c.name_the_combinator('oneOf', CreativeManifest._branch_models(), CreativeManifest._ROOT_REQUIRED) + ), +] + +# CreativeManifest is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +CreativeManifest.model_rebuild(force=True) + + +_ReadbackCreativeManifest = readback_clone( + "_ReadbackCreativeManifest", + # Not one branch's shape -- a readback node accepts a manifest instance of + # either branch -- so it copies the base they share. + shared_base(CreativeManifest), + # No field override: ``format_kind`` is generated as ``str`` (see + # OPEN_VOCABULARY_SCHEMAS in scripts/generate_types.py), so there is nothing left + # to widen. What this clone carries is instance normalization -- a readback node + # accepts a manifest INSTANCE of a sibling class, which a plain field does not. + validators={ + "_coerce_standalone_assets": model_validator(mode="before")( + readback_normalizer(CreativeManifest, "_coerce_standalone_assets") + ), + }, +) diff --git a/src/adcp/types/domains/core/creative_operation_format_declaration.py b/src/adcp/types/domains/core/creative_operation_format_declaration.py index 939a09e67..64c118870 100644 --- a/src/adcp/types/domains/core/creative_operation_format_declaration.py +++ b/src/adcp/types/domains/core/creative_operation_format_declaration.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/creative_operation_format_declaration.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, Field, StrictBool +from pydantic import AnyUrl, Field, StrictBool, model_validator from ..enums import channels from . import creative_locale_policy, format_id, macro_resolution_capability, platform_extension_ref +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class SellerPreference(StrEnum): preferred = 'preferred' accepted = 'accepted' @@ -61,3 +67,61 @@ class CreativeOperationFormatDeclaration(AdCPBaseModel): description='Canonical creative-shape parameters. Validate against the schema selected by format_kind; custom params validate against the fetched format_schema.' ), ] + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreativeOperationFormatDeclaration: + # Compiled from core/creative-operation-format-declaration.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'tracker_execution_contract') or _c.supplied(self, 'tracker_execution_contract_digest')) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeOperationFormatDeclaration violates a constraint its schema states outside its properties (core/creative-operation-format-declaration.json, root conjunct 0) over tracker_execution_contract, tracker_execution_contract_digest', + {'fields': 'tracker_execution_contract, tracker_execution_contract_digest'}, + ) + if not ( + (_c.sent(self, 'format_option_id') if _c.sent(self, 'publisher_domain') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeOperationFormatDeclaration violates a constraint its schema states outside its properties (core/creative-operation-format-declaration.json, root conjunct 1) over format_option_id, publisher_domain', + {'fields': 'format_option_id, publisher_domain'}, + ) + if not ( + ((((_c.descend(self, 'canonical_formats_only', lambda canonical_formats_only: canonical_formats_only == True) and _c.sent(self, 'canonical_formats_only')) or _c.sent(self, 'v1_format_ref')) and (_c.sent(self, 'format_shape') and _c.sent(self, 'format_schema'))) if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'custom') and _c.sent(self, 'format_kind')) else not (_c.sent(self, 'format_shape') or _c.sent(self, 'format_schema'))) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeOperationFormatDeclaration violates a constraint its schema states outside its properties (core/creative-operation-format-declaration.json, root conjunct 2) over canonical_formats_only, format_kind, format_schema, format_shape, v1_format_ref', + {'fields': 'canonical_formats_only, format_kind, format_schema, format_shape, v1_format_ref'}, + ) + if not ( + not ((_c.descend(self, 'canonical_formats_only', lambda canonical_formats_only: canonical_formats_only == True) and _c.sent(self, 'canonical_formats_only')) and _c.sent(self, 'v1_format_ref')) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeOperationFormatDeclaration violates a constraint its schema states outside its properties (core/creative-operation-format-declaration.json, root conjunct 3) over canonical_formats_only, v1_format_ref', + {'fields': 'canonical_formats_only, v1_format_ref'}, + ) + if not ( + not (_c.supplied(self, 'capability_id')) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeOperationFormatDeclaration violates a constraint its schema states outside its properties (core/creative-operation-format-declaration.json, root conjunct 4) over capability_id', + {'fields': 'capability_id'}, + ) + if not ( + ((_c.descend(self, 'canonical_formats_only', lambda canonical_formats_only: canonical_formats_only == True) and _c.sent(self, 'canonical_formats_only')) if _c.sent(self, 'locale_policy') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeOperationFormatDeclaration violates a constraint its schema states outside its properties (core/creative-operation-format-declaration.json, root conjunct 5) over canonical_formats_only, locale_policy', + {'fields': 'canonical_formats_only, locale_policy'}, + ) + return self diff --git a/src/adcp/types/domains/core/creative_representation.py b/src/adcp/types/domains/core/creative_representation.py index 9b2021655..397cfc501 100644 --- a/src/adcp/types/domains/core/creative_representation.py +++ b/src/adcp/types/domains/core/creative_representation.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/creative_representation.json -# timestamp: 2026-10-05T14:13:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -12,6 +12,12 @@ from .creative_manifest import CreativeManifest +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Source(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -70,3 +76,30 @@ def _reject_seller_bound_manifest_fields(cls, data: Any) -> Any: 'creative representations must not include ' + ', '.join(present) ) return data + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreativeRepresentation: + # Compiled from core/creative-representation.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'format_id') or _c.sent(self, 'format_option_ref') or _c.sent(self, 'representation_selection')) + and _c.sent(self, 'format_kind') + ): + raise PydanticCustomError( + 'allOf', + 'CreativeRepresentation violates a constraint its schema states outside its properties (core/creative-representation.json, root conjunct 0) over format_id, format_kind, format_option_ref, representation_selection', + {'fields': 'format_id, format_kind, format_option_ref, representation_selection'}, + ) + if not ( + (_c.descend(self, 'assets', lambda assets: _c.matching_keys(assets, '.*', lambda item: (_c.supplied(item, 'vast_version') if (_c.descend(item, 'asset_type', lambda asset_type: asset_type == 'vast') and _c.supplied(item, 'asset_type') and _c.json_type(item, 'object')) else True) and (_c.every(item, lambda item: (_c.supplied(item, 'vast_version') if (_c.descend(item, 'asset_type', lambda asset_type: asset_type == 'vast') and _c.supplied(item, 'asset_type') and _c.json_type(item, 'object')) else True)) if _c.json_type(item, 'array') else True))) if (_c.descend(self, 'format_kind', lambda format_kind: format_kind in ['video_vast', 'audio_vast']) and _c.sent(self, 'format_kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CreativeRepresentation violates a constraint its schema states outside its properties (core/creative-representation.json, root conjunct 1) over asset_type, assets, format_kind, vast_version', + {'fields': 'asset_type, assets, format_kind, vast_version'}, + ) + return self diff --git a/src/adcp/types/domains/core/creative_variant.py b/src/adcp/types/domains/core/creative_variant.py index 5b0571c9e..405e58c22 100644 --- a/src/adcp/types/domains/core/creative_variant.py +++ b/src/adcp/types/domains/core/creative_variant.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/creative_variant.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -15,6 +15,9 @@ from .delivery_metrics import DeliveryMetrics +from adcp.types._readback import readback_clone + + class Artifact(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -77,3 +80,10 @@ class CreativeVariant(DeliveryMetrics): description='Input signals that triggered generation of this variant (Tier 3). Describes why the platform created this specific variant. Platforms should provide summarized or anonymized signals rather than raw user input. For web contexts, may include page topic or URL. For conversational contexts, an anonymized content signal. For search, query category or intent. When the content context is managed through AdCP content standards, reference the artifact directly via the artifact field.' ), ] = None + + +_DeliveryVariant = readback_clone( + "_DeliveryVariant", + CreativeVariant, + {"manifest": creative_manifest._ReadbackCreativeManifest | None}, +) diff --git a/src/adcp/types/domains/core/daast_tracker_constraints.py b/src/adcp/types/domains/core/daast_tracker_constraints.py index e28dfb3b8..11de2cace 100644 --- a/src/adcp/types/domains/core/daast_tracker_constraints.py +++ b/src/adcp/types/domains/core/daast_tracker_constraints.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/daast_tracker_constraints.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import Field, RootModel +from pydantic import Field, RootModel, model_validator from ..enums import daast_version +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class DaastEvent(StrEnum): creativeView = 'creativeView' start = 'start' @@ -50,3 +56,37 @@ class DaastTrackerConstraints(AdCPBaseModel): target: DaastTarget | None = DaastTarget.linear offset: DaastOffset | None = None daast_versions: DaastVersions | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> DaastTrackerConstraints: + # Compiled from core/daast-tracker-constraints.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'daast_event', lambda daast_event: daast_event in ['creativeView', 'start', 'firstQuartile', 'midpoint', 'thirdQuartile', 'complete', 'mute', 'unmute', 'pause', 'resume', 'rewind', 'skip', 'progress']) if (not (_c.sent(self, 'target')) or (_c.descend(self, 'target', lambda target: target == 'linear') and _c.sent(self, 'target'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'DaastTrackerConstraints violates a constraint its schema states outside its properties (core/daast-tracker-constraints.json, root conjunct 0) over daast_event, target', + {'fields': 'daast_event, target'}, + ) + if not ( + (_c.descend(self, 'daast_event', lambda daast_event: daast_event == 'creativeView') if (_c.descend(self, 'target', lambda target: target == 'companion') and _c.sent(self, 'target')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'DaastTrackerConstraints violates a constraint its schema states outside its properties (core/daast-tracker-constraints.json, root conjunct 1) over daast_event, target', + {'fields': 'daast_event, target'}, + ) + if not ( + (_c.sent(self, 'offset') if (_c.descend(self, 'daast_event', lambda daast_event: daast_event == 'progress') and _c.sent(self, 'daast_event')) else not (_c.sent(self, 'offset'))) + ): + raise PydanticCustomError( + 'allOf', + 'DaastTrackerConstraints violates a constraint its schema states outside its properties (core/daast-tracker-constraints.json, root conjunct 2) over daast_event, offset', + {'fields': 'daast_event, offset'}, + ) + return self diff --git a/src/adcp/types/domains/core/deadline_policy.py b/src/adcp/types/domains/core/deadline_policy.py index 1f01c52cd..a8967c05d 100644 --- a/src/adcp/types/domains/core/deadline_policy.py +++ b/src/adcp/types/domains/core/deadline_policy.py @@ -1,13 +1,19 @@ # generated by datamodel-codegen: # filename: core/deadline_policy.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class MaterialStage(AdCPBaseModel): @@ -52,3 +58,20 @@ class DeadlinePolicy(AdCPBaseModel): description='When true, lead_days counts business days (Mon-Fri) rather than calendar days. Defaults to false.' ), ] = False + @model_validator(mode='after') + def _require_schema_required_group(self) -> DeadlinePolicy: + # Compiled from core/deadline-policy.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'DeadlinePolicy violates a constraint its schema states outside its properties (core/deadline-policy.json, root conjunct 0)', + ) + return self diff --git a/src/adcp/types/domains/core/delivery_metrics.py b/src/adcp/types/domains/core/delivery_metrics.py index ebd4f2f33..41728e010 100644 --- a/src/adcp/types/domains/core/delivery_metrics.py +++ b/src/adcp/types/domains/core/delivery_metrics.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/delivery_metrics.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..enums import action_source as action_source_1 from ..enums import event_type as event_type_1 @@ -18,6 +18,12 @@ from . import brand_ref, duration, placement_evidence, vendor_metric_value +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Kind(StrEnum): cumulative = 'cumulative' period = 'period' @@ -729,3 +735,21 @@ class DeliveryMetrics(AdCPBaseModel): description="Reported values for vendor-defined metrics that the product's `reporting_capabilities.vendor_metrics` declared. Each entry carries the vendor (BrandRef), the metric identifier within the vendor's vocabulary, the value, optional unit, and `measurable_impressions` as the coverage denominator — vendor measurement is rarely 100% of delivered impressions, since vendors only score impressions where their SDK fires or their panel matches. When a declared vendor metric is omitted from this array, buyers infer no measurement happened (no integration). One row per `(vendor.domain, vendor.brand_id, metric_id, qualifier)` per reporting period — the same vendor metric MAY appear in multiple rows only when each carries a distinct qualifier (e.g., 7-day and 30-day attribution windows); sellers MUST de-duplicate before emission and MUST NOT emit two rows with the same tuple; buyers MAY treat duplicate rows as a seller-side conformance bug. The structured `vendor_metric_values` array is the recommended path for vendor metrics; `additionalProperties: true` on this parent object is preserved so existing free-form vendor emissions remain conformant during migration." ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> DeliveryMetrics: + # Compiled from core/delivery-metrics.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'reach')) or _c.supplied(self, 'reach_unit') + ): + raise PydanticCustomError( + 'allOf', + 'DeliveryMetrics violates a constraint its schema states outside its properties (core/delivery-metrics.json, root conjunct 0) over reach, reach_unit', + {'fields': 'reach, reach_unit'}, + ) + return self diff --git a/src/adcp/types/domains/core/delivery_recipient.py b/src/adcp/types/domains/core/delivery_recipient.py index 4eadba187..9a15155af 100644 --- a/src/adcp/types/domains/core/delivery_recipient.py +++ b/src/adcp/types/domains/core/delivery_recipient.py @@ -1,17 +1,23 @@ # generated by datamodel-codegen: # filename: core/delivery_recipient.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import delivery_recipient_cloud +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class DeliveryRecipient(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -19,3 +25,29 @@ class DeliveryRecipient(AdCPBaseModel): identity: Annotated[str, Field(max_length=512, min_length=1)] cloud: delivery_recipient_cloud.DeliveryRecipientCloud | None = None region: Annotated[str | None, Field(max_length=128, min_length=1)] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> DeliveryRecipient: + # Compiled from core/delivery-recipient.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'cloud')) or _c.supplied(self, 'region') + ): + raise PydanticCustomError( + 'allOf', + 'DeliveryRecipient violates a constraint its schema states outside its properties (core/delivery-recipient.json, root conjunct 0) over cloud, region', + {'fields': 'cloud, region'}, + ) + if not ( + not (_c.supplied(self, 'region')) or _c.supplied(self, 'cloud') + ): + raise PydanticCustomError( + 'allOf', + 'DeliveryRecipient violates a constraint its schema states outside its properties (core/delivery-recipient.json, root conjunct 1) over cloud, region', + {'fields': 'cloud, region'}, + ) + return self diff --git a/src/adcp/types/domains/core/demographic_age_range.py b/src/adcp/types/domains/core/demographic_age_range.py index 80b37faaa..c2ed11c42 100644 --- a/src/adcp/types/domains/core/demographic_age_range.py +++ b/src/adcp/types/domains/core/demographic_age_range.py @@ -1,15 +1,18 @@ # generated by datamodel-codegen: # filename: core/demographic_age_range.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel, SchemaInt from pydantic import ConfigDict, Field, StrictBool, model_validator +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -39,19 +42,29 @@ class DemographicAgeRange(AdCPBaseModel): description='Whether delivery to people whose age is unavailable is part of this predicate. This field has no default and MUST be supplied.' ), ] + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'max', 'min'}) @model_validator(mode='after') def _require_schema_required_group(self) -> DemographicAgeRange: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('min',), ('max',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'DemographicAgeRange requires at least one of these field groups: min | max', - ) + # Compiled from core/demographic-age-range.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # min + ( + _c.sent(self, 'min') + ) + # max + or ( + _c.sent(self, 'max') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'DemographicAgeRange requires at least one of these field groups: min | max', + ) + return self diff --git a/src/adcp/types/domains/core/demographic_reporting_capability.py b/src/adcp/types/domains/core/demographic_reporting_capability.py index 259f9c477..175f0bca2 100644 --- a/src/adcp/types/domains/core/demographic_reporting_capability.py +++ b/src/adcp/types/domains/core/demographic_reporting_capability.py @@ -1,11 +1,11 @@ # generated by datamodel-codegen: # filename: core/demographic_reporting_capability.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel, SchemaInt from pydantic import ConfigDict, Field, StrictBool, model_validator @@ -14,6 +14,9 @@ from . import demographic_age_range +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -106,19 +109,29 @@ class DemographicReportingCapability(AdCPBaseModel): description='Whether privacy, policy, or measurement thresholds may suppress otherwise reportable demographic rows. When true, buyers must inspect by_demographic_suppressed before testing whether rows reconcile to package totals.' ), ] + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'age', 'demographic_systems'}) @model_validator(mode='after') def _require_schema_required_group(self) -> DemographicReportingCapability: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('age',), ('demographic_systems',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'DemographicReportingCapability requires at least one of these field groups: age | demographic_systems', - ) + # Compiled from core/demographic-reporting-capability.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # age + ( + _c.sent(self, 'age') + ) + # demographic_systems + or ( + _c.sent(self, 'demographic_systems') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'DemographicReportingCapability requires at least one of these field groups: age | demographic_systems', + ) + return self diff --git a/src/adcp/types/domains/core/demographic_targeting_resolution.py b/src/adcp/types/domains/core/demographic_targeting_resolution.py index 20bc8cbdb..576f89b77 100644 --- a/src/adcp/types/domains/core/demographic_targeting_resolution.py +++ b/src/adcp/types/domains/core/demographic_targeting_resolution.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: core/demographic_targeting_resolution.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import age_determination_basis, age_verification_method from . import demographic_predicate, demographic_targeting_intent @@ -16,6 +15,13 @@ from . import signal_ref +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class IntervalId(ScalarStr): __slots__ = () _constraints = {'min_length': 1} @@ -95,3 +101,45 @@ class DemographicTargetingResolution(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> DemographicTargetingResolution: + # Compiled from core/demographic-targeting-resolution.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'difference_reason') or _c.supplied(self, 'buyer_approved')) + ): + raise PydanticCustomError( + 'allOf', + 'DemographicTargetingResolution violates a constraint its schema states outside its properties (core/demographic-targeting-resolution.json, root conjunct 0) over buyer_approved, difference_reason', + {'fields': 'buyer_approved, difference_reason'}, + ) + if not ( + (_c.sent(self, 'applied_bases') if _c.descend(self, 'requested', lambda requested: _c.descend(requested, 'age', lambda age: _c.supplied(age, 'accepted_bases'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'DemographicTargetingResolution violates a constraint its schema states outside its properties (core/demographic-targeting-resolution.json, root conjunct 1) over accepted_bases, age, applied_bases, requested', + {'fields': 'accepted_bases, age, applied_bases, requested'}, + ) + if not ( + ((_c.descend(self, 'applied_bases', lambda applied_bases: _c.some(applied_bases, lambda item: item == 'verified')) and _c.sent(self, 'applied_bases')) if _c.sent(self, 'applied_verification_methods') else True) + ): + raise PydanticCustomError( + 'allOf', + 'DemographicTargetingResolution violates a constraint its schema states outside its properties (core/demographic-targeting-resolution.json, root conjunct 2) over applied_bases, applied_verification_methods', + {'fields': 'applied_bases, applied_verification_methods'}, + ) + if not ( + (_c.sent(self, 'applied_verification_methods') if (_c.descend(self, 'applied_bases', lambda applied_bases: _c.some(applied_bases, lambda item: item == 'verified')) and _c.sent(self, 'applied_bases')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'DemographicTargetingResolution violates a constraint its schema states outside its properties (core/demographic-targeting-resolution.json, root conjunct 3) over applied_bases, applied_verification_methods', + {'fields': 'applied_bases, applied_verification_methods'}, + ) + return self diff --git a/src/adcp/types/domains/core/downstream_connection_requirement.py b/src/adcp/types/domains/core/downstream_connection_requirement.py index 8477377df..7ee3a2ffe 100644 --- a/src/adcp/types/domains/core/downstream_connection_requirement.py +++ b/src/adcp/types/domains/core/downstream_connection_requirement.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/downstream_connection_requirement.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,8 +8,14 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator + + +from adcp.types import _conditions as _c from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field + + +from pydantic_core import PydanticCustomError class ConnectionType(StrEnum): @@ -134,3 +140,21 @@ class DownstreamConnectionRequirement(AdCPBaseModel): AwareDatetime | None, Field(description='Expiration time for the downstream grant, when known.'), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> DownstreamConnectionRequirement: + # Compiled from core/downstream-connection-requirement.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'provider') or _c.sent(self, 'authorization_url')) if (_c.descend(self, 'status', lambda status: status in ['missing', 'pending', 'expired', 'revoked']) and _c.sent(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'DownstreamConnectionRequirement violates a constraint its schema states outside its properties (core/downstream-connection-requirement.json, root conjunct 0) over authorization_url, provider, status', + {'fields': 'authorization_url, provider, status'}, + ) + return self diff --git a/src/adcp/types/domains/core/error.py b/src/adcp/types/domains/core/error.py index 755bed423..cefdd488e 100644 --- a/src/adcp/types/domains/core/error.py +++ b/src/adcp/types/domains/core/error.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/error.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class BuyerReason(AdCPBaseModel): @@ -165,3 +171,21 @@ class Error(AdCPBaseModel): description='Optional identifier for the SDK that augmented this error entry. Format: `<sdk_package_name>@<version>` (e.g., `@adcontextprotocol/adcp@7.3.0`, `adcontextprotocol-adcp-python@1.2.0`). MUST be set when `source: "sdk"`; MUST be absent when `source: "producer"` or absent. Lets downstream consumers identify which intermediate processor inserted the entry, useful for debugging cross-SDK divergence (e.g., one SDK detects a projection failure that another SDK\'s registry version doesn\'t).' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> Error: + # Compiled from core/error.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'buyer_reason')) or _c.supplied(self, 'recovery') + ): + raise PydanticCustomError( + 'allOf', + 'Error violates a constraint its schema states outside its properties (core/error.json, root conjunct 0) over buyer_reason, recovery', + {'fields': 'buyer_reason, recovery'}, + ) + return self diff --git a/src/adcp/types/domains/core/event.py b/src/adcp/types/domains/core/event.py index 96c9afd97..ae64b9dfd 100644 --- a/src/adcp/types/domains/core/event.py +++ b/src/adcp/types/domains/core/event.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/event.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator from ..enums import action_source as action_source_1 from ..enums import event_type as event_type_1 @@ -16,6 +16,12 @@ from . import user_match as user_match_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Event(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -59,3 +65,21 @@ class Event(AdCPBaseModel): str | None, Field(description="Name for custom events (used when event_type is 'custom')") ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> Event: + # Compiled from core/event.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.descend(self, 'custom_data', lambda custom_data: _c.sent(custom_data, 'progress_percent') or _c.sent(custom_data, 'progress_seconds')) and _c.sent(self, 'custom_data')) if (_c.descend(self, 'event_type', lambda event_type: event_type == 'watch_milestone') and _c.sent(self, 'event_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Event violates a constraint its schema states outside its properties (core/event.json, root conjunct 0) over custom_data, event_type, progress_percent, progress_seconds', + {'fields': 'custom_data, event_type, progress_percent, progress_seconds'}, + ) + return self diff --git a/src/adcp/types/domains/core/forecast_dimension_geo.py b/src/adcp/types/domains/core/forecast_dimension_geo.py index c53d9c428..437dc9784 100644 --- a/src/adcp/types/domains/core/forecast_dimension_geo.py +++ b/src/adcp/types/domains/core/forecast_dimension_geo.py @@ -1,17 +1,23 @@ # generated by datamodel-codegen: # filename: core/forecast_dimension_geo.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import geo_level as geo_level_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class GeoForecastDimension(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -46,3 +52,45 @@ class GeoForecastDimension(AdCPBaseModel): description="Human-readable geographic name (e.g., 'United States', 'California', 'New York DMA')." ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GeoForecastDimension: + # Compiled from core/forecast-dimension-geo.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((not (_c.sent(self, 'system') or _c.sent(self, 'country')) and _c.descend(self, 'geo_code', lambda geo_code: _c.matches(geo_code, '^[A-Z]{2}$'))) if (_c.descend(self, 'geo_level', lambda geo_level: geo_level == 'country') and _c.sent(self, 'geo_level')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GeoForecastDimension violates a constraint its schema states outside its properties (core/forecast-dimension-geo.json, root conjunct 0) over country, geo_code, geo_level, system', + {'fields': 'country, geo_code, geo_level, system'}, + ) + if not ( + ((not (_c.sent(self, 'system') or _c.sent(self, 'country')) and _c.descend(self, 'geo_code', lambda geo_code: _c.matches(geo_code, '^[A-Z]{2}-[A-Z0-9]{1,3}$'))) if (_c.descend(self, 'geo_level', lambda geo_level: geo_level == 'region') and _c.sent(self, 'geo_level')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GeoForecastDimension violates a constraint its schema states outside its properties (core/forecast-dimension-geo.json, root conjunct 1) over country, geo_code, geo_level, system', + {'fields': 'country, geo_code, geo_level, system'}, + ) + if not ( + ((not (_c.sent(self, 'country')) and _c.descend(self, 'system', lambda system: system in ['nielsen_dma', 'uk_itl1', 'uk_itl2', 'eurostat_nuts2', 'custom']) and _c.sent(self, 'system')) if (_c.descend(self, 'geo_level', lambda geo_level: geo_level == 'metro') and _c.sent(self, 'geo_level')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GeoForecastDimension violates a constraint its schema states outside its properties (core/forecast-dimension-geo.json, root conjunct 2) over country, geo_level, system', + {'fields': 'country, geo_level, system'}, + ) + if not ( + (((((_c.descend(self, 'country', lambda country: country == 'US') and _c.descend(self, 'system', lambda system: system in ['zip', 'zip_plus_four'])) or (_c.descend(self, 'country', lambda country: country == 'GB') and _c.descend(self, 'system', lambda system: system in ['outward', 'full'])) or (_c.descend(self, 'country', lambda country: country == 'CA') and _c.descend(self, 'system', lambda system: system in ['fsa', 'full'])) or (_c.descend(self, 'country', lambda country: country in ['DE', 'CH', 'AT']) and _c.descend(self, 'system', lambda system: system == 'plz')) or (_c.descend(self, 'country', lambda country: country == 'FR') and _c.descend(self, 'system', lambda system: system == 'code_postal')) or (_c.descend(self, 'country', lambda country: country == 'AU') and _c.descend(self, 'system', lambda system: system == 'postcode')) or (_c.descend(self, 'country', lambda country: country == 'BR') and _c.descend(self, 'system', lambda system: system == 'cep')) or (_c.descend(self, 'country', lambda country: country == 'IN') and _c.descend(self, 'system', lambda system: system == 'pin')) or (_c.descend(self, 'country', lambda country: country == 'ZA') and _c.descend(self, 'system', lambda system: system == 'postal_code')) or (_c.descend(self, 'country', lambda country: not (country in ['US', 'GB', 'CA', 'DE', 'CH', 'AT', 'FR', 'AU', 'BR', 'IN', 'ZA'])) and _c.descend(self, 'system', lambda system: system in ['postal_code', 'custom']))) and _c.descend(self, 'system', lambda system: system in ['postal_code', 'zip', 'zip_plus_four', 'outward', 'full', 'fsa', 'plz', 'code_postal', 'postcode', 'cep', 'pin', 'custom', 'us_zip', 'us_zip_plus_four', 'gb_outward', 'gb_full', 'ca_fsa', 'ca_full', 'de_plz', 'fr_code_postal', 'au_postcode', 'ch_plz', 'at_plz']) and (_c.sent(self, 'country') and _c.sent(self, 'system'))) or (not (_c.sent(self, 'country')) and _c.descend(self, 'system', lambda system: system in ['us_zip', 'us_zip_plus_four', 'gb_outward', 'gb_full', 'ca_fsa', 'ca_full', 'de_plz', 'fr_code_postal', 'au_postcode', 'ch_plz', 'at_plz']) and _c.sent(self, 'system'))) if (_c.descend(self, 'geo_level', lambda geo_level: geo_level == 'postal_area') and _c.sent(self, 'geo_level')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GeoForecastDimension violates a constraint its schema states outside its properties (core/forecast-dimension-geo.json, root conjunct 3) over country, geo_level, system', + {'fields': 'country, geo_level, system'}, + ) + return self diff --git a/src/adcp/types/domains/core/forecast_dimension_signal.py b/src/adcp/types/domains/core/forecast_dimension_signal.py index 2105ae6cb..3f6ce1667 100644 --- a/src/adcp/types/domains/core/forecast_dimension_signal.py +++ b/src/adcp/types/domains/core/forecast_dimension_signal.py @@ -1,11 +1,11 @@ # generated by datamodel-codegen: # filename: core/forecast_dimension_signal.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum -from typing import Annotated, Literal +from typing import Annotated, ClassVar, Literal from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator @@ -13,6 +13,9 @@ from . import signal_ref as signal_ref_1 +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -60,19 +63,45 @@ class SignalForecastDimension(AdCPBaseModel): signal_value_name: Annotated[ str | None, Field(description='Human-readable label for the signal value bucket.') ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'signal_id', 'signal_ref'}) @model_validator(mode='after') def _require_schema_required_group(self) -> SignalForecastDimension: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('signal_ref',), ('signal_id',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'SignalForecastDimension requires at least one of these field groups: signal_ref | signal_id', - ) + # Compiled from core/forecast-dimension-signal.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # signal_ref + ( + _c.sent(self, 'signal_ref') + ) + # signal_id + or ( + _c.sent(self, 'signal_id') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'SignalForecastDimension requires at least one of these field groups: signal_ref | signal_id', + ) + if not ( + ((_c.descend(self, 'signal_value', lambda signal_value: _c.json_type(signal_value, 'null')) and _c.supplied(self, 'signal_value')) if (_c.descend(self, 'presence', lambda presence: presence == 'absent') and _c.sent(self, 'presence')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalForecastDimension violates a constraint its schema states outside its properties (core/forecast-dimension-signal.json, root conjunct 0) over presence, signal_value', + {'fields': 'presence, signal_value'}, + ) + if not ( + (_c.descend(self, 'signal_value', lambda signal_value: _c.json_type(signal_value, 'string', 'number', 'boolean')) if (_c.descend(self, 'presence', lambda presence: presence == 'present') and _c.sent(self, 'presence')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalForecastDimension violates a constraint its schema states outside its properties (core/forecast-dimension-signal.json, root conjunct 1) over presence, signal_value', + {'fields': 'presence, signal_value'}, + ) + return self diff --git a/src/adcp/types/domains/core/forecast_range.py b/src/adcp/types/domains/core/forecast_range.py index fd237d1c2..282f2cb07 100644 --- a/src/adcp/types/domains/core/forecast_range.py +++ b/src/adcp/types/domains/core/forecast_range.py @@ -1,15 +1,18 @@ # generated by datamodel-codegen: # filename: core/forecast_range.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, StrictFloat, model_validator +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -26,19 +29,29 @@ class ForecastRange(AdCPBaseModel): high: Annotated[ StrictFloat | None, Field(description='Optimistic (high-end) forecast value', ge=0.0) ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'high', 'low', 'mid'}) @model_validator(mode='after') def _require_schema_required_group(self) -> ForecastRange: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('mid',), ('low', 'high'),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'ForecastRange requires at least one of these field groups: mid | low+high', - ) + # Compiled from core/forecast-range.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # mid + ( + _c.sent(self, 'mid') + ) + # low+high + or ( + _c.sent(self, 'low') and _c.sent(self, 'high') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'ForecastRange requires at least one of these field groups: mid | low+high', + ) + return self diff --git a/src/adcp/types/domains/core/format.py b/src/adcp/types/domains/core/format.py index b2132f820..038173505 100644 --- a/src/adcp/types/domains/core/format.py +++ b/src/adcp/types/domains/core/format.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/format.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Any, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool, StrictFloat +from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool, StrictFloat, model_validator from ..enums import ( available_metric, @@ -59,6 +59,15 @@ ) +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + +from pydantic import Discriminator, Tag + + class Responsive(AdCPBaseModel): width: StrictBool height: StrictBool @@ -532,6 +541,93 @@ class Assets45(BaseGroupAsset): ] +class UnknownGroupAsset(BaseGroupAsset): + """Fallback branch for an ``asset_type`` outside this pin's vocabulary. + + A response carrying an asset type defined after this SDK's pin parses as this + class instead of failing, which would zero out every format in the response. + The structural fields are still validated; the type-specific ones are kept in + ``__pydantic_extra__``. + + ``extra='allow'`` is deliberate and not inherited from the strict-validation + policy on ``AdCPBaseModel``: dropping or refusing the unknown fields defeats + the whole purpose of the branch. Read path only -- a request keeps its closed + ``Literal`` branches. + """ + + model_config = ConfigDict(extra='allow') + asset_type: str + + +#: The ``asset_type`` values this pin has a class for. Derived from the branches +#: above, so a schema that adds a type adds its class AND its tag together. +_KNOWN_BASEGROUPASSET_TYPES: frozenset[str] = frozenset( + { + 'image', + 'video', + 'audio', + 'text', + 'markdown', + 'html', + 'css', + 'javascript', + 'zip', + 'vast', + 'daast', + 'url', + 'webhook', + 'brief', + 'display_tag', + 'published_post', + 'card', + 'pixel_tracker', + 'vast_tracker', + 'daast_tracker', + 'catalog', + } +) + + +def _group_asset_discriminator(value: Any) -> str: + """Route a wire asset to its ``Tag``, or to the fallback.""" + asset_type = ( + value.get('asset_type', '') + if isinstance(value, dict) + else getattr(value, 'asset_type', '') + ) + return asset_type if asset_type in _KNOWN_BASEGROUPASSET_TYPES else '_unknown' + + +GroupFormatAssetUnion = Annotated[ + Annotated[Assets31, Tag('image')] + | Annotated[Assets32, Tag('video')] + | Annotated[Assets33, Tag('audio')] + | Annotated[Assets34, Tag('text')] + | Annotated[Assets35, Tag('markdown')] + | Annotated[Assets36, Tag('html')] + | Annotated[Assets37, Tag('css')] + | Annotated[Assets38, Tag('javascript')] + | Annotated[Assets39, Tag('zip')] + | Annotated[Assets40, Tag('vast')] + | Annotated[Assets41, Tag('daast')] + | Annotated[Assets42, Tag('url')] + | Annotated[Assets43, Tag('webhook')] + | Annotated[Assets44, Tag('brief')] + | Annotated[Assets46, Tag('display_tag')] + | Annotated[Assets47, Tag('published_post')] + | Annotated[Assets48, Tag('card')] + | Annotated[Assets49, Tag('pixel_tracker')] + | Annotated[Assets50, Tag('vast_tracker')] + | Annotated[Assets51, Tag('daast_tracker')] + | Annotated[Assets45, Tag('catalog')] + | Annotated[UnknownGroupAsset, Tag('_unknown')] + , + Discriminator(_group_asset_discriminator), +] +"""Open discriminated union for the slots inside a repeatable asset group.\n\n The generated union closed over the group asset classes; this adds the\n fallback branch an unrecognised ``asset_type`` needs. +""" + + class Assets29(AdCPBaseModel): item_type: Annotated[ Literal['repeatable_group'], @@ -563,7 +659,7 @@ class Assets29(AdCPBaseModel): ), ] = SelectionMode.sequential assets: Annotated[ - list[Assets30], Field(description='Assets within each repetition of this group') + list[GroupFormatAssetUnion], Field(description='Assets within each repetition of this group') ] @@ -2253,6 +2349,97 @@ def __getattr__(self, name: str) -> Any: +class UnknownFormatAsset(BaseIndividualAsset): + """Fallback branch for an ``asset_type`` outside this pin's vocabulary. + + A response carrying an asset type defined after this SDK's pin parses as this + class instead of failing, which would zero out every format in the response. + The structural fields are still validated; the type-specific ones are kept in + ``__pydantic_extra__``. + + ``extra='allow'`` is deliberate and not inherited from the strict-validation + policy on ``AdCPBaseModel``: dropping or refusing the unknown fields defeats + the whole purpose of the branch. Read path only -- a request keeps its closed + ``Literal`` branches. + """ + + model_config = ConfigDict(extra='allow') + asset_type: str + + +#: The ``asset_type`` values this pin has a class for. Derived from the branches +#: above, so a schema that adds a type adds its class AND its tag together. +_KNOWN_BASEINDIVIDUALASSET_TYPES: frozenset[str] = frozenset( + { + 'image', + 'video', + 'audio', + 'text', + 'markdown', + 'html', + 'css', + 'javascript', + 'zip', + 'vast', + 'daast', + 'url', + 'webhook', + 'brief', + 'display_tag', + 'published_post', + 'card', + 'pixel_tracker', + 'vast_tracker', + 'daast_tracker', + 'catalog', + } +) + + +def _format_asset_discriminator(value: Any) -> str: + """Route a wire asset to its ``Tag``, or to the fallback.""" + item_type = value.get('item_type', 'individual') if isinstance(value, dict) else getattr(value, 'item_type', 'individual') + if item_type == 'repeatable_group': + return 'repeatable_group' + asset_type = ( + value.get('asset_type', '') + if isinstance(value, dict) + else getattr(value, 'asset_type', '') + ) + return asset_type if asset_type in _KNOWN_BASEINDIVIDUALASSET_TYPES else '_unknown' + + +FormatAssetUnion = Annotated[ + Annotated[Assets, Tag('image')] + | Annotated[Assets9, Tag('video')] + | Annotated[Assets10, Tag('audio')] + | Annotated[Assets11, Tag('text')] + | Annotated[Assets12, Tag('markdown')] + | Annotated[Assets13, Tag('html')] + | Annotated[Assets14, Tag('css')] + | Annotated[Assets15, Tag('javascript')] + | Annotated[Assets16, Tag('zip')] + | Annotated[Assets17, Tag('vast')] + | Annotated[Assets18, Tag('daast')] + | Annotated[Assets19, Tag('url')] + | Annotated[Assets20, Tag('webhook')] + | Annotated[Assets21, Tag('brief')] + | Annotated[Assets23, Tag('display_tag')] + | Annotated[Assets24, Tag('published_post')] + | Annotated[Assets25, Tag('card')] + | Annotated[Assets26, Tag('pixel_tracker')] + | Annotated[Assets27, Tag('vast_tracker')] + | Annotated[Assets28, Tag('daast_tracker')] + | Annotated[Assets22, Tag('catalog')] + | Annotated[Assets29, Tag('repeatable_group')] + | Annotated[UnknownFormatAsset, Tag('_unknown')] + , + Discriminator(_format_asset_discriminator), +] +"""Open discriminated union for ``Format.assets``.\n\n The generated union closed over the individual asset classes plus the\n repeatable group; this adds the fallback branch an unrecognised\n ``asset_type`` needs. +""" + + class Format(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -2290,30 +2477,7 @@ class Format(AdCPBaseModel): ), ] = None assets: Annotated[ - list[ - Assets - | Assets9 - | Assets10 - | Assets11 - | Assets12 - | Assets13 - | Assets14 - | Assets15 - | Assets16 - | Assets17 - | Assets18 - | Assets19 - | Assets20 - | Assets21 - | Assets22 - | Assets23 - | Assets24 - | Assets25 - | Assets26 - | Assets27 - | Assets28 - | Assets29 - ] + list[FormatAssetUnion] | None, Field( description="Array of all assets supported for this format. Each asset is identified by its asset_id, which must be used as the key in creative manifests. Use the 'required' boolean on each asset to indicate whether it's mandatory." @@ -2403,6 +2567,24 @@ class Format(AdCPBaseModel): description="**DEPRECATED in 3.1. Removed at 4.0.** Use `v1_format_ref` on the v2 `ProductFormatDeclaration` instead — the seller authors a v2 declaration (in `Product.format_options` or `creative.supported_formats`) and links it back to this v1 format via `v1_format_ref: { agent_url, id }`. The directional link from v2 → v1 is the same fact as `canonical_parameters` without the parallel-shape drift surface (v1 file and `canonical_parameters` were two declarations of the same thing; hand-authored, drifting silently).\n\nMigration: every seller currently authoring `canonical_parameters` SHOULD migrate to authoring a v2 declaration on the corresponding product (or capability) with `v1_format_ref` pointing back at this v1 format. v1 files become pure v1 again — no v2-shape mirroring.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* When `canonical` is set, this field carries the full ProductFormatDeclaration that the SDK projects this v1 format into. The `format_kind` MUST equal the `canonical` field value (validators enforce). When set, this is the authoritative source for SDK v1→v2 projection — the registry's structural-match parameter inference is bypassed. SDKs reading 3.1 catalogs MUST continue to honor `canonical_parameters` when present; 4.0+ SDKs MAY reject the field. New code SHOULD NOT emit this field. Seller execution authority is never projected through this deprecated field, so tracker_execution_contract and tracker_execution_contract_digest are forbidden.\n\n**Drift contract (still normative while supported).** Hand-authored `canonical_parameters` MUST satisfy the *narrows* relation against this v1 format's `requirements` and `assets[*]` shape (see canonical-formats.mdx 'Narrows — formal definition'). SDKs that read this v1 file SHOULD lint-time check the equivalence at build/load and emit `FORMAT_PROJECTION_FAILED` if the two disagree.", ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> Format: + # Compiled from core/format.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.descend(self, 'canonical_parameters', lambda canonical_parameters: _c.json_type(canonical_parameters, 'object')) and _c.sent(self, 'canonical')) if _c.sent(self, 'canonical_parameters') else True) + ): + raise PydanticCustomError( + 'allOf', + 'Format violates a constraint its schema states outside its properties (core/format.json, root conjunct 0) over canonical, canonical_parameters', + {'fields': 'canonical, canonical_parameters'}, + ) + return self # Backward compatibility for the pre-beta3 generated repeatable group name. diff --git a/src/adcp/types/domains/core/format_id.py b/src/adcp/types/domains/core/format_id.py index ef25372f8..056f8c860 100644 --- a/src/adcp/types/domains/core/format_id.py +++ b/src/adcp/types/domains/core/format_id.py @@ -1,13 +1,19 @@ # generated by datamodel-codegen: # filename: core/format_id.json -# timestamp: 2026-10-05T03:58:57+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt, WireUrl -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class FormatReferenceStructuredObject(AdCPBaseModel): @@ -55,3 +61,38 @@ class FormatReferenceStructuredObject(AdCPBaseModel): gt=0.0, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> FormatReferenceStructuredObject: + # Compiled from core/format-id.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'height')) or _c.supplied(self, 'width') + ): + raise PydanticCustomError( + 'allOf', + 'FormatReferenceStructuredObject violates a constraint its schema states outside its properties (core/format-id.json, root conjunct 0) over height, width', + {'fields': 'height, width'}, + ) + if not ( + not (_c.supplied(self, 'pixel_ratio')) + or (_c.supplied(self, 'width') and _c.supplied(self, 'height')) + ): + raise PydanticCustomError( + 'allOf', + 'FormatReferenceStructuredObject violates a constraint its schema states outside its properties (core/format-id.json, root conjunct 1) over height, pixel_ratio, width', + {'fields': 'height, pixel_ratio, width'}, + ) + if not ( + not (_c.supplied(self, 'width')) or _c.supplied(self, 'height') + ): + raise PydanticCustomError( + 'allOf', + 'FormatReferenceStructuredObject violates a constraint its schema states outside its properties (core/format-id.json, root conjunct 2) over height, width', + {'fields': 'height, width'}, + ) + return self diff --git a/src/adcp/types/domains/core/frequency_cap.py b/src/adcp/types/domains/core/frequency_cap.py index 6c05043c0..28a0bf465 100644 --- a/src/adcp/types/domains/core/frequency_cap.py +++ b/src/adcp/types/domains/core/frequency_cap.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: core/frequency_cap.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel, SchemaInt from pydantic import ConfigDict, Field, StrictFloat, model_validator @@ -13,6 +13,9 @@ from . import duration +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -53,19 +56,58 @@ class FrequencyCap(AdCPBaseModel): description='Time window for the max_impressions cap (e.g. {"interval": 7, "unit": "days"} or {"interval": 1, "unit": "campaign"} for the full flight). Required when max_impressions is set.' ), ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'max_impressions', 'suppress', 'suppress_minutes'}) @model_validator(mode='after') def _require_schema_required_group(self) -> FrequencyCap: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('suppress',), ('suppress_minutes',), ('max_impressions',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'FrequencyCap requires at least one of these field groups: suppress | suppress_minutes | max_impressions', - ) + # Compiled from core/frequency-cap.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # suppress + ( + _c.sent(self, 'suppress') + ) + # suppress_minutes + or ( + _c.sent(self, 'suppress_minutes') + ) + # max_impressions + or ( + _c.sent(self, 'max_impressions') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'FrequencyCap requires at least one of these field groups: suppress | suppress_minutes | max_impressions', + ) + if not ( + not (_c.supplied(self, 'max_impressions')) + or (_c.supplied(self, 'per') and _c.supplied(self, 'window')) + ): + raise PydanticCustomError( + 'allOf', + 'FrequencyCap violates a constraint its schema states outside its properties (core/frequency-cap.json, root conjunct 0) over max_impressions, per, window', + {'fields': 'max_impressions, per, window'}, + ) + if not ( + not (_c.supplied(self, 'per')) or _c.supplied(self, 'max_impressions') + ): + raise PydanticCustomError( + 'allOf', + 'FrequencyCap violates a constraint its schema states outside its properties (core/frequency-cap.json, root conjunct 1) over max_impressions, per', + {'fields': 'max_impressions, per'}, + ) + if not ( + not (_c.supplied(self, 'window')) or _c.supplied(self, 'max_impressions') + ): + raise PydanticCustomError( + 'allOf', + 'FrequencyCap violates a constraint its schema states outside its properties (core/frequency-cap.json, root conjunct 2) over max_impressions, window', + {'fields': 'max_impressions, window'}, + ) + return self diff --git a/src/adcp/types/domains/core/frequency_cap_constraints.py b/src/adcp/types/domains/core/frequency_cap_constraints.py index f2b42c145..390c95022 100644 --- a/src/adcp/types/domains/core/frequency_cap_constraints.py +++ b/src/adcp/types/domains/core/frequency_cap_constraints.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: core/frequency_cap_constraints.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, model_validator @@ -14,6 +14,9 @@ from . import frequency_cap_impression_constraints, frequency_cap_interval_constraints +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -57,19 +60,45 @@ class FrequencyCapConstraints(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'max_impressions_constraints', 'mutable_fields', 'supported_control_modes', 'supported_per_units', 'suppression_constraints', 'window_constraints'}) @model_validator(mode='after') def _require_schema_required_group(self) -> FrequencyCapConstraints: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('mutable_fields',), ('supported_control_modes',), ('supported_per_units',), ('max_impressions_constraints',), ('window_constraints',), ('suppression_constraints',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'FrequencyCapConstraints requires at least one of these field groups: mutable_fields | supported_control_modes | supported_per_units | max_impressions_constraints | window_constraints | suppression_constraints', - ) + # Compiled from core/frequency-cap-constraints.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # mutable_fields + ( + _c.sent(self, 'mutable_fields') + ) + # supported_control_modes + or ( + _c.sent(self, 'supported_control_modes') + ) + # supported_per_units + or ( + _c.sent(self, 'supported_per_units') + ) + # max_impressions_constraints + or ( + _c.sent(self, 'max_impressions_constraints') + ) + # window_constraints + or ( + _c.sent(self, 'window_constraints') + ) + # suppression_constraints + or ( + _c.sent(self, 'suppression_constraints') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'FrequencyCapConstraints requires at least one of these field groups: mutable_fields | supported_control_modes | supported_per_units | max_impressions_constraints | window_constraints | suppression_constraints', + ) + return self diff --git a/src/adcp/types/domains/core/frequency_cap_impression_constraints.py b/src/adcp/types/domains/core/frequency_cap_impression_constraints.py index 1adf67658..adf20d97f 100644 --- a/src/adcp/types/domains/core/frequency_cap_impression_constraints.py +++ b/src/adcp/types/domains/core/frequency_cap_impression_constraints.py @@ -1,27 +1,51 @@ # generated by datamodel-codegen: # filename: core/frequency_cap_impression_constraints.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any, ClassVar, TypeAlias from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarInt -from pydantic import ConfigDict, Field, model_validator +from pydantic import ConfigDict, Field, WrapValidator, model_validator from . import ext as ext_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarInt + + from pydantic_core import PydanticCustomError +from adcp.types._branch_dispatch import BranchDispatchMixin + + class AllowedValue(ScalarInt): __slots__ = () _constraints = {'ge': 1} -class FrequencyCapImpressionConstraints(AdCPBaseModel): +class FrequencyCapImpressionConstraints(BranchDispatchMixin, AdCPBaseModel): + """What every branch of core/frequency-cap-impression-constraints.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``FrequencyCapImpressionConstraintsAllowedValues`` + ``FrequencyCapImpressionConstraintsMaximumMinimum`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + FrequencyCapImpressionConstraintsBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + model_config = ConfigDict( extra='forbid', ) @@ -29,19 +53,136 @@ class FrequencyCapImpressionConstraints(AdCPBaseModel): maximum: Annotated[SchemaInt | None, Field(ge=1)] = None allowed_values: Annotated[list[AllowedValue] | None, Field(min_length=1)] = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'allowed_values', 'maximum', 'minimum'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + FrequencyCapImpressionConstraintsAllowedValues, + FrequencyCapImpressionConstraintsMaximumMinimum, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' @model_validator(mode='after') def _require_schema_required_group(self) -> FrequencyCapImpressionConstraints: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('allowed_values',), ('minimum', 'maximum'),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'oneOf', - 'FrequencyCapImpressionConstraints requires at least one of these field groups: allowed_values | minimum+maximum', + # Compiled from core/frequency-cap-impression-constraints.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # allowed_values + ( + not (_c.sent(self, 'minimum') or _c.sent(self, 'maximum')) + and _c.sent(self, 'allowed_values') + ) + # minimum+maximum + or ( + not (_c.sent(self, 'allowed_values')) + and (_c.sent(self, 'minimum') and _c.sent(self, 'maximum')) + ) + ): + raise PydanticCustomError( + 'oneOf', + 'FrequencyCapImpressionConstraints requires at least one of these field groups: allowed_values | minimum+maximum', + ) + if [(not (_c.sent(self, 'minimum') or _c.sent(self, 'maximum')) and _c.sent(self, 'allowed_values')), (not (_c.sent(self, 'allowed_values')) and (_c.sent(self, 'minimum') and _c.sent(self, 'maximum')))].count(True) != 1: + raise PydanticCustomError( + 'oneOf', + 'FrequencyCapImpressionConstraints satisfies more than one of these mutually exclusive field groups: allowed_values | minimum+maximum', + ) + return self + +class FrequencyCapImpressionConstraintsAllowedValues(FrequencyCapImpressionConstraints): + """The oneOf/0 branch. + + One branch of core/frequency-cap-impression-constraints.json, stated as a shape: oneOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + maximum: None = None + minimum: None = None + allowed_values: Annotated[list[AllowedValue], Field(min_length=1)] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'maximum', 'minimum'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) + ) + + +class FrequencyCapImpressionConstraintsMaximumMinimum(FrequencyCapImpressionConstraints): + """The oneOf/1 branch. + + One branch of core/frequency-cap-impression-constraints.json, stated as a shape: oneOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + allowed_values: None = None + maximum: Annotated[SchemaInt, Field(ge=1)] + minimum: Annotated[SchemaInt, Field(ge=1)] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'allowed_values'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) ) + + +#: core/frequency-cap-impression-constraints.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate FrequencyCapImpressionConstraints to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +FrequencyCapImpressionConstraintsBranches: TypeAlias = Annotated[ + FrequencyCapImpressionConstraintsAllowedValues | FrequencyCapImpressionConstraintsMaximumMinimum, + WrapValidator( + _c.name_the_combinator('oneOf', FrequencyCapImpressionConstraints._branch_models(), FrequencyCapImpressionConstraints._ROOT_REQUIRED) + ), +] + +# FrequencyCapImpressionConstraints is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +FrequencyCapImpressionConstraints.model_rebuild(force=True) diff --git a/src/adcp/types/domains/core/frequency_cap_interval_constraints.py b/src/adcp/types/domains/core/frequency_cap_interval_constraints.py index d4e37447c..a750e30b0 100644 --- a/src/adcp/types/domains/core/frequency_cap_interval_constraints.py +++ b/src/adcp/types/domains/core/frequency_cap_interval_constraints.py @@ -1,28 +1,52 @@ # generated by datamodel-codegen: # filename: core/frequency_cap_interval_constraints.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, Any, ClassVar, TypeAlias from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarInt -from pydantic import ConfigDict, Field, model_validator +from pydantic import ConfigDict, Field, WrapValidator, model_validator from . import ext as ext_1 from . import frequency_cap_duration_unit +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarInt + + from pydantic_core import PydanticCustomError +from adcp.types._branch_dispatch import BranchDispatchMixin + + class AllowedInterval(ScalarInt): __slots__ = () _constraints = {'ge': 1} -class FrequencyCapIntervalConstraints(AdCPBaseModel): +class FrequencyCapIntervalConstraints(BranchDispatchMixin, AdCPBaseModel): + """What every branch of core/frequency-cap-interval-constraints.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``FrequencyCapIntervalConstraintsAllowedIntervals`` + ``FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + FrequencyCapIntervalConstraintsBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + model_config = ConfigDict( extra='forbid', ) @@ -31,19 +55,172 @@ class FrequencyCapIntervalConstraints(AdCPBaseModel): maximum_interval: Annotated[SchemaInt | None, Field(ge=1)] = None allowed_intervals: Annotated[list[AllowedInterval] | None, Field(min_length=1)] = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'allowed_intervals', 'maximum_interval', 'minimum_interval'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'unit'}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + FrequencyCapIntervalConstraintsAllowedIntervals, + FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'oneOf' @model_validator(mode='after') def _require_schema_required_group(self) -> FrequencyCapIntervalConstraints: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('allowed_intervals',), ('minimum_interval', 'maximum_interval'),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'oneOf', - 'FrequencyCapIntervalConstraints requires at least one of these field groups: allowed_intervals | minimum_interval+maximum_interval', + # Compiled from core/frequency-cap-interval-constraints.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # allowed_intervals + ( + not (_c.sent(self, 'minimum_interval') or _c.sent(self, 'maximum_interval')) + and _c.sent(self, 'allowed_intervals') + ) + # minimum_interval+maximum_interval + or ( + not (_c.sent(self, 'allowed_intervals')) + and (_c.sent(self, 'minimum_interval') and _c.sent(self, 'maximum_interval')) + ) + ): + raise PydanticCustomError( + 'oneOf', + 'FrequencyCapIntervalConstraints requires at least one of these field groups: allowed_intervals | minimum_interval+maximum_interval', + ) + if [(not (_c.sent(self, 'minimum_interval') or _c.sent(self, 'maximum_interval')) and _c.sent(self, 'allowed_intervals')), (not (_c.sent(self, 'allowed_intervals')) and (_c.sent(self, 'minimum_interval') and _c.sent(self, 'maximum_interval')))].count(True) != 1: + raise PydanticCustomError( + 'oneOf', + 'FrequencyCapIntervalConstraints satisfies more than one of these mutually exclusive field groups: allowed_intervals | minimum_interval+maximum_interval', + ) + if not ( + ((_c.descend(self, 'allowed_intervals', lambda allowed_intervals: _c.every(allowed_intervals, lambda item: item == 1)) and _c.descend(self, 'maximum_interval', lambda maximum_interval: maximum_interval == 1) and _c.descend(self, 'minimum_interval', lambda minimum_interval: minimum_interval == 1)) if _c.descend(self, 'unit', lambda unit: unit == 'campaign') else True) + ): + raise PydanticCustomError( + 'allOf', + 'FrequencyCapIntervalConstraints violates a constraint its schema states outside its properties (core/frequency-cap-interval-constraints.json, root conjunct 0) over allowed_intervals, maximum_interval, minimum_interval, unit', + {'fields': 'allowed_intervals, maximum_interval, minimum_interval, unit'}, + ) + return self + +class FrequencyCapIntervalConstraintsAllowedIntervals(FrequencyCapIntervalConstraints): + """The oneOf/0 branch. + + One branch of core/frequency-cap-interval-constraints.json, stated as a shape: oneOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + maximum_interval: None = None + minimum_interval: None = None + allowed_intervals: Annotated[list[AllowedInterval], Field(min_length=1)] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'maximum_interval', 'minimum_interval'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> FrequencyCapIntervalConstraintsAllowedIntervals: + # Compiled from core/frequency-cap-interval-constraints.json oneOf/0: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + ((_c.descend(self, 'allowed_intervals', lambda allowed_intervals: _c.every(allowed_intervals, lambda item: item == 1)) and _c.descend(self, 'maximum_interval', lambda maximum_interval: maximum_interval == 1) and _c.descend(self, 'minimum_interval', lambda minimum_interval: minimum_interval == 1)) if _c.descend(self, 'unit', lambda unit: unit == 'campaign') else True) + ): + raise PydanticCustomError( + 'oneOf', + 'FrequencyCapIntervalConstraintsAllowedIntervals violates what core/frequency-cap-interval-constraints.json states for its oneOf/0 branch beyond the fields it requires and forbids', + ) + return self + + +class FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval(FrequencyCapIntervalConstraints): + """The oneOf/1 branch. + + One branch of core/frequency-cap-interval-constraints.json, stated as a shape: oneOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + allowed_intervals: None = None + maximum_interval: Annotated[SchemaInt, Field(ge=1)] + minimum_interval: Annotated[SchemaInt, Field(ge=1)] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'allowed_intervals'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) + ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval: + # Compiled from core/frequency-cap-interval-constraints.json oneOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + ((_c.descend(self, 'allowed_intervals', lambda allowed_intervals: _c.every(allowed_intervals, lambda item: item == 1)) and _c.descend(self, 'maximum_interval', lambda maximum_interval: maximum_interval == 1) and _c.descend(self, 'minimum_interval', lambda minimum_interval: minimum_interval == 1)) if _c.descend(self, 'unit', lambda unit: unit == 'campaign') else True) + ): + raise PydanticCustomError( + 'oneOf', + 'FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval violates what core/frequency-cap-interval-constraints.json states for its oneOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +#: core/frequency-cap-interval-constraints.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate FrequencyCapIntervalConstraints to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +FrequencyCapIntervalConstraintsBranches: TypeAlias = Annotated[ + FrequencyCapIntervalConstraintsAllowedIntervals | FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval, + WrapValidator( + _c.name_the_combinator('oneOf', FrequencyCapIntervalConstraints._branch_models(), FrequencyCapIntervalConstraints._ROOT_REQUIRED) + ), +] + +# FrequencyCapIntervalConstraints is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +FrequencyCapIntervalConstraints.model_rebuild(force=True) diff --git a/src/adcp/types/domains/core/geo_place_area.py b/src/adcp/types/domains/core/geo_place_area.py index 5268362c4..c07839d54 100644 --- a/src/adcp/types/domains/core/geo_place_area.py +++ b/src/adcp/types/domains/core/geo_place_area.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: core/geo_place_area.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from . import ext as ext_1 from . import geo_place_system, geo_place_type +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Value(ScalarStr): __slots__ = () _constraints = {'min_length': 1} @@ -53,3 +59,21 @@ class GeographicPlaceArea(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GeographicPlaceArea: + # Compiled from core/geo-place-area.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'values', lambda values: _c.every(values, lambda item: _c.matches(item, '^[0-9]+$'))) if (_c.descend(self, 'system', lambda system: system in ['geonames', 'google_ads', 'microsoft_ads']) and _c.sent(self, 'system')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GeographicPlaceArea violates a constraint its schema states outside its properties (core/geo-place-area.json, root conjunct 0) over system, values', + {'fields': 'system, values'}, + ) + return self diff --git a/src/adcp/types/domains/core/get_geo_place_resolution_request.py b/src/adcp/types/domains/core/get_geo_place_resolution_request.py index 9aaefa7ff..f70dabd9f 100644 --- a/src/adcp/types/domains/core/get_geo_place_resolution_request.py +++ b/src/adcp/types/domains/core/get_geo_place_resolution_request.py @@ -1,21 +1,45 @@ # generated by datamodel-codegen: # filename: core/get_geo_place_resolution_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar, TypeAlias from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, model_validator +from pydantic import ConfigDict, Field, WrapValidator, model_validator from . import geo_place_type, locale_tag +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError -class GetGeographicPlaceResolutionRequest(AdCPBaseModel): +from adcp.types._branch_dispatch import BranchDispatchMixin + + +class GetGeographicPlaceResolutionRequest(BranchDispatchMixin, AdCPBaseModel): + """What every branch of core/get-geo-place-resolution-request.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``GetGeographicPlaceResolutionRequestQ`` + ``GetGeographicPlaceResolutionRequestValue`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + GetGeographicPlaceResolutionRequestBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + model_config = ConfigDict( extra='forbid', ) @@ -55,19 +79,133 @@ class GetGeographicPlaceResolutionRequest(AdCPBaseModel): ] = None cursor: Annotated[str | None, Field(min_length=1)] = None limit: Annotated[SchemaInt | None, Field(ge=1, le=100)] = 20 + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'q', 'value'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'country'}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + GetGeographicPlaceResolutionRequestQ, + GetGeographicPlaceResolutionRequestValue, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'anyOf' @model_validator(mode='after') def _require_schema_required_group(self) -> GetGeographicPlaceResolutionRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('q',), ('value',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'GetGeographicPlaceResolutionRequest requires at least one of these field groups: q | value', - ) + # Compiled from core/get-geo-place-resolution-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # q + ( + _c.sent(self, 'q') + ) + # value + or ( + _c.sent(self, 'value') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'GetGeographicPlaceResolutionRequest requires at least one of these field groups: q | value', + ) + if not ( + not (_c.sent(self, 'q') and _c.sent(self, 'value')) + ): + raise PydanticCustomError( + 'allOf', + 'GetGeographicPlaceResolutionRequest violates a constraint its schema states outside its properties (core/get-geo-place-resolution-request.json, root conjunct 0) over q, value', + {'fields': 'q, value'}, + ) + return self + +class GetGeographicPlaceResolutionRequestQ(GetGeographicPlaceResolutionRequest): + """The anyOf/0 branch. + + One branch of core/get-geo-place-resolution-request.json, stated as a shape: anyOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + q: Annotated[str, Field(description='Unresolved place name or alias supplied by the user.', min_length=1)] + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetGeographicPlaceResolutionRequestQ: + # Compiled from core/get-geo-place-resolution-request.json anyOf/0: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + not (_c.sent(self, 'q') and _c.sent(self, 'value')) + ): + raise PydanticCustomError( + 'anyOf', + 'GetGeographicPlaceResolutionRequestQ violates what core/get-geo-place-resolution-request.json states for its anyOf/0 branch beyond the fields it requires and forbids', + ) + return self + + +class GetGeographicPlaceResolutionRequestValue(GetGeographicPlaceResolutionRequest): + """The anyOf/1 branch. + + One branch of core/get-geo-place-resolution-request.json, stated as a shape: anyOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + value: Annotated[str, Field(description='Existing identifier to look up in the requested/current catalog version for lifecycle refresh or replacement discovery. Mutually exclusive with q.', min_length=1)] + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetGeographicPlaceResolutionRequestValue: + # Compiled from core/get-geo-place-resolution-request.json anyOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + not (_c.sent(self, 'q') and _c.sent(self, 'value')) + ): + raise PydanticCustomError( + 'anyOf', + 'GetGeographicPlaceResolutionRequestValue violates what core/get-geo-place-resolution-request.json states for its anyOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +#: core/get-geo-place-resolution-request.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate GetGeographicPlaceResolutionRequest to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +GetGeographicPlaceResolutionRequestBranches: TypeAlias = Annotated[ + GetGeographicPlaceResolutionRequestQ | GetGeographicPlaceResolutionRequestValue, + WrapValidator( + _c.name_the_combinator('anyOf', GetGeographicPlaceResolutionRequest._branch_models(), GetGeographicPlaceResolutionRequest._ROOT_REQUIRED) + ), +] + +# GetGeographicPlaceResolutionRequest is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +GetGeographicPlaceResolutionRequest.model_rebuild(force=True) diff --git a/src/adcp/types/domains/core/get_geo_place_resolution_response.py b/src/adcp/types/domains/core/get_geo_place_resolution_response.py index b1cef6059..ee82d3459 100644 --- a/src/adcp/types/domains/core/get_geo_place_resolution_response.py +++ b/src/adcp/types/domains/core/get_geo_place_resolution_response.py @@ -1,17 +1,23 @@ # generated by datamodel-codegen: # filename: core/get_geo_place_resolution_response.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from . import geo_place_catalog_entry, geo_place_system, get_geo_place_resolution_request +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class GetGeographicPlaceResolutionResponse(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -26,3 +32,21 @@ class GetGeographicPlaceResolutionResponse(AdCPBaseModel): system_version: Annotated[str, Field(min_length=1)] matches: list[geo_place_catalog_entry.GeographicPlaceCatalogEntry] next_cursor: Annotated[str | None, Field(min_length=1)] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetGeographicPlaceResolutionResponse: + # Compiled from core/get-geo-place-resolution-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'matches', lambda matches: _c.every(matches, lambda item: _c.descend(item, 'replaced_by_values', lambda replaced_by_values: _c.every(replaced_by_values, lambda item: _c.matches(item, '^[0-9]+$'))) and _c.descend(item, 'value', lambda value: _c.matches(value, '^[0-9]+$')))) if (_c.descend(self, 'system', lambda system: system in ['geonames', 'google_ads', 'microsoft_ads']) and _c.sent(self, 'system')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetGeographicPlaceResolutionResponse violates a constraint its schema states outside its properties (core/get-geo-place-resolution-response.json, root conjunct 0) over matches, replaced_by_values, system, value', + {'fields': 'matches, replaced_by_values, system, value'}, + ) + return self diff --git a/src/adcp/types/domains/core/indicator_bearing.py b/src/adcp/types/domains/core/indicator_bearing.py index 3d5b17295..bc2c95596 100644 --- a/src/adcp/types/domains/core/indicator_bearing.py +++ b/src/adcp/types/domains/core/indicator_bearing.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: core/indicator_bearing.json -# timestamp: 2026-08-17T23:02:13+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..enums import indicator_type from . import indicator, indicator_scope +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class IndicatorBearingResourceState(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -43,3 +49,113 @@ class IndicatorBearingResourceState(AdCPBaseModel): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> IndicatorBearingResourceState: + # Compiled from core/indicator-bearing.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'indicator_types_evaluated')) + or (_c.supplied(self, 'indicators') and _c.supplied(self, 'indicators_as_of')) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 0) over indicator_types_evaluated, indicators, indicators_as_of', + {'fields': 'indicator_types_evaluated, indicators, indicators_as_of'}, + ) + if not ( + not (_c.supplied(self, 'indicators')) + or (_c.supplied(self, 'indicator_types_evaluated') and _c.supplied(self, 'indicators_as_of')) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 1) over indicator_types_evaluated, indicators, indicators_as_of', + {'fields': 'indicator_types_evaluated, indicators, indicators_as_of'}, + ) + if not ( + not (_c.supplied(self, 'indicators_as_of')) + or (_c.supplied(self, 'indicators') and _c.supplied(self, 'indicator_types_evaluated')) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 2) over indicator_types_evaluated, indicators, indicators_as_of', + {'fields': 'indicator_types_evaluated, indicators, indicators_as_of'}, + ) + if not ( + not (_c.supplied(self, 'indicators_evaluated_scope')) + or (_c.supplied(self, 'indicators') and _c.supplied(self, 'indicator_types_evaluated') and _c.supplied(self, 'indicators_as_of')) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 3) over indicator_types_evaluated, indicators, indicators_as_of, indicators_evaluated_scope', + {'fields': 'indicator_types_evaluated, indicators, indicators_as_of, indicators_evaluated_scope'}, + ) + if not ( + (_c.descend(self, 'indicators', lambda indicators: _c.every(indicators, lambda item: _c.sent(item, 'scope'))) if _c.sent(self, 'indicators_evaluated_scope') else True) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 4) over indicators, indicators_evaluated_scope, scope', + {'fields': 'indicators, indicators_evaluated_scope, scope'}, + ) + if not ( + (_c.descend(self, 'indicator_types_evaluated', lambda indicator_types_evaluated: _c.some(indicator_types_evaluated, lambda item: item == 'creative_fatigue')) if (_c.descend(self, 'indicators', lambda indicators: _c.some(indicators, lambda item: _c.descend(item, 'type', lambda type: type == 'creative_fatigue') and _c.supplied(item, 'type'))) and _c.sent(self, 'indicators')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 5) over indicator_types_evaluated, indicators, type', + {'fields': 'indicator_types_evaluated, indicators, type'}, + ) + if not ( + (_c.descend(self, 'indicator_types_evaluated', lambda indicator_types_evaluated: _c.some(indicator_types_evaluated, lambda item: item == 'creative_quality_opportunity')) if (_c.descend(self, 'indicators', lambda indicators: _c.some(indicators, lambda item: _c.descend(item, 'type', lambda type: type == 'creative_quality_opportunity') and _c.supplied(item, 'type'))) and _c.sent(self, 'indicators')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 6) over indicator_types_evaluated, indicators, type', + {'fields': 'indicator_types_evaluated, indicators, type'}, + ) + if not ( + (_c.descend(self, 'indicator_types_evaluated', lambda indicator_types_evaluated: _c.some(indicator_types_evaluated, lambda item: item == 'creative_diversity_low')) if (_c.descend(self, 'indicators', lambda indicators: _c.some(indicators, lambda item: _c.descend(item, 'type', lambda type: type == 'creative_diversity_low') and _c.supplied(item, 'type'))) and _c.sent(self, 'indicators')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 7) over indicator_types_evaluated, indicators, type', + {'fields': 'indicator_types_evaluated, indicators, type'}, + ) + if not ( + (_c.descend(self, 'indicator_types_evaluated', lambda indicator_types_evaluated: _c.some(indicator_types_evaluated, lambda item: item == 'audience_saturation')) if (_c.descend(self, 'indicators', lambda indicators: _c.some(indicators, lambda item: _c.descend(item, 'type', lambda type: type == 'audience_saturation') and _c.supplied(item, 'type'))) and _c.sent(self, 'indicators')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 8) over indicator_types_evaluated, indicators, type', + {'fields': 'indicator_types_evaluated, indicators, type'}, + ) + if not ( + (_c.descend(self, 'indicator_types_evaluated', lambda indicator_types_evaluated: _c.some(indicator_types_evaluated, lambda item: item == 'inventory_shortfall_forecast')) if (_c.descend(self, 'indicators', lambda indicators: _c.some(indicators, lambda item: _c.descend(item, 'type', lambda type: type == 'inventory_shortfall_forecast') and _c.supplied(item, 'type'))) and _c.sent(self, 'indicators')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 9) over indicator_types_evaluated, indicators, type', + {'fields': 'indicator_types_evaluated, indicators, type'}, + ) + if not ( + (_c.descend(self, 'indicator_types_evaluated', lambda indicator_types_evaluated: _c.some(indicator_types_evaluated, lambda item: item == 'pacing_risk')) if (_c.descend(self, 'indicators', lambda indicators: _c.some(indicators, lambda item: _c.descend(item, 'type', lambda type: type == 'pacing_risk') and _c.supplied(item, 'type'))) and _c.sent(self, 'indicators')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 10) over indicator_types_evaluated, indicators, type', + {'fields': 'indicator_types_evaluated, indicators, type'}, + ) + if not ( + (_c.descend(self, 'indicator_types_evaluated', lambda indicator_types_evaluated: _c.some(indicator_types_evaluated, lambda item: item == 'budget_constrained')) if (_c.descend(self, 'indicators', lambda indicators: _c.some(indicators, lambda item: _c.descend(item, 'type', lambda type: type == 'budget_constrained') and _c.supplied(item, 'type'))) and _c.sent(self, 'indicators')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorBearingResourceState violates a constraint its schema states outside its properties (core/indicator-bearing.json, root conjunct 11) over indicator_types_evaluated, indicators, type', + {'fields': 'indicator_types_evaluated, indicators, type'}, + ) + return self diff --git a/src/adcp/types/domains/core/indicators_changed_webhook.py b/src/adcp/types/domains/core/indicators_changed_webhook.py index 9f5a27f27..a442f399d 100644 --- a/src/adcp/types/domains/core/indicators_changed_webhook.py +++ b/src/adcp/types/domains/core/indicators_changed_webhook.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/indicators_changed_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..enums import indicator_type from . import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class RelationshipKind(StrEnum): media_buy = 'media_buy' package = 'package' @@ -80,3 +86,29 @@ class IndicatorsChangedWebhook(AdCPBaseModel): AwareDatetime, Field(description='When the seller observed the semantic snapshot change.') ] ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> IndicatorsChangedWebhook: + # Compiled from core/indicators-changed-webhook.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'package_id') if (_c.descend(self, 'relationship_kind', lambda relationship_kind: relationship_kind == 'package') and _c.sent(self, 'relationship_kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorsChangedWebhook violates a constraint its schema states outside its properties (core/indicators-changed-webhook.json, root conjunct 0) over package_id, relationship_kind', + {'fields': 'package_id, relationship_kind'}, + ) + if not ( + ((_c.sent(self, 'package_id') and _c.sent(self, 'creative_id')) if (_c.descend(self, 'relationship_kind', lambda relationship_kind: relationship_kind == 'creative_assignment') and _c.sent(self, 'relationship_kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'IndicatorsChangedWebhook violates a constraint its schema states outside its properties (core/indicators-changed-webhook.json, root conjunct 1) over creative_id, package_id, relationship_kind', + {'fields': 'creative_id, package_id, relationship_kind'}, + ) + return self diff --git a/src/adcp/types/domains/core/installment_deadlines.py b/src/adcp/types/domains/core/installment_deadlines.py index 98619ea9a..03352de4d 100644 --- a/src/adcp/types/domains/core/installment_deadlines.py +++ b/src/adcp/types/domains/core/installment_deadlines.py @@ -1,17 +1,23 @@ # generated by datamodel-codegen: # filename: core/installment_deadlines.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import material_deadline +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class InstallmentDeadlines(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -35,3 +41,20 @@ class InstallmentDeadlines(AdCPBaseModel): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> InstallmentDeadlines: + # Compiled from core/installment-deadlines.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'InstallmentDeadlines violates a constraint its schema states outside its properties (core/installment-deadlines.json, root conjunct 0)', + ) + return self diff --git a/src/adcp/types/domains/core/macro_declaration.py b/src/adcp/types/domains/core/macro_declaration.py index 5d8879a08..f8ad486a4 100644 --- a/src/adcp/types/domains/core/macro_declaration.py +++ b/src/adcp/types/domains/core/macro_declaration.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/macro_declaration.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from pydantic import AnyUrl, ConfigDict, Field, StrictBool, model_validator from .assets import asset_union +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Field1(StrEnum): url = 'url' markup = 'markup' @@ -121,3 +127,133 @@ class MacroDeclaration(AdCPBaseModel): description='Action when the responsible actor has no value. Omission is legal only for a complete URL query-value occurrence; dialect sentinels require a cited dialect rule.' ), ] + @model_validator(mode='after') + def _require_schema_required_group(self) -> MacroDeclaration: + # Compiled from core/macro-declaration.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'dialect_namespace') and _c.sent(self, 'dialect_revision')) if (_c.descend(self, 'dialect', lambda dialect: dialect in ['iab_vast', 'iab_daast', 'vendor']) and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 0) over dialect, dialect_namespace, dialect_revision', + {'fields': 'dialect, dialect_namespace, dialect_revision'}, + ) + if not ( + (not (_c.sent(self, 'dialect_namespace') or _c.sent(self, 'dialect_revision')) if (_c.descend(self, 'dialect', lambda dialect: dialect in ['adcp', 'unknown']) and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 1) over dialect, dialect_namespace, dialect_revision', + {'fields': 'dialect, dialect_namespace, dialect_revision'}, + ) + if not ( + (_c.sent(self, 'universal_semantic') if (_c.descend(self, 'mapping_status', lambda mapping_status: mapping_status == 'verified_universal') and _c.sent(self, 'mapping_status')) else not (_c.sent(self, 'universal_semantic'))) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 2) over mapping_status, universal_semantic', + {'fields': 'mapping_status, universal_semantic'}, + ) + if not ( + (((_c.descend(self, 'dialect_semantic', lambda dialect_semantic: dialect_semantic in ['MEDIA_BUY_ID', 'PACKAGE_ID', 'CREATIVE_ID', 'CACHEBUSTER', 'TIMESTAMP', 'CLICK_URL', 'GDPR', 'GDPR_CONSENT', 'US_PRIVACY', 'GPP_STRING', 'GPP_SID', 'IP_ADDRESS', 'LIMIT_AD_TRACKING', 'DEVICE_TYPE', 'OS', 'OS_VERSION', 'DEVICE_MAKE', 'DEVICE_MODEL', 'USER_AGENT', 'APP_BUNDLE', 'APP_NAME', 'COUNTRY', 'REGION', 'CITY', 'ZIP', 'DMA', 'LAT', 'LONG', 'DEVICE_ID', 'DEVICE_ID_TYPE', 'DOMAIN', 'PAGE_URL', 'REFERRER', 'KEYWORDS', 'PLACEMENT_ID', 'FOLD_POSITION', 'AD_WIDTH', 'AD_HEIGHT', 'VIDEO_ID', 'VIDEO_TITLE', 'VIDEO_DURATION', 'VIDEO_CATEGORY', 'CONTENT_GENRE', 'CONTENT_RATING', 'PLAYER_WIDTH', 'PLAYER_HEIGHT', 'POD_POSITION', 'POD_SIZE', 'AD_BREAK_ID', 'STATION_ID', 'COLLECTION_NAME', 'INSTALLMENT_ID', 'AUDIO_DURATION', 'TMPX', 'IMPRESSION_ID', 'AXEM', 'CATALOG_ID', 'SKU', 'GTIN', 'OFFERING_ID', 'JOB_ID', 'HOTEL_ID', 'FLIGHT_ID', 'VEHICLE_ID', 'LISTING_ID', 'STORE_ID', 'PROGRAM_ID', 'DESTINATION_ID', 'CREATIVE_VARIANT_ID', 'APP_ITEM_ID', 'ITEM_NAME', 'ITEM_DESCRIPTION', 'ITEM_TAGLINE', 'ITEM_PRICE', 'ITEM_PRICE_CURRENCY']) and _c.descend(self, 'mapping_status', lambda mapping_status: mapping_status == 'verified_universal')) and _c.sent(self, 'universal_semantic')) if (_c.descend(self, 'dialect', lambda dialect: dialect == 'adcp') and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 3) over dialect, dialect_semantic, mapping_status, universal_semantic', + {'fields': 'dialect, dialect_semantic, mapping_status, universal_semantic'}, + ) + if not ( + ((not (_c.sent(self, 'performed_by') or _c.sent(self, 'translation_target') or _c.sent(self, 'universal_semantic')) and (_c.descend(self, 'encoding', lambda encoding: _c.descend(encoding, 'depth', lambda depth: depth == 0) and _c.descend(encoding, 'kind', lambda kind: kind == 'none')) and _c.descend(self, 'mapping_status', lambda mapping_status: mapping_status == 'unresolved') and _c.descend(self, 'operation', lambda operation: operation == 'preserve') and _c.descend(self, 'required', lambda required: required == False) and _c.descend(self, 'unavailable_behavior', lambda unavailable_behavior: unavailable_behavior == 'preserve'))) if (_c.descend(self, 'dialect', lambda dialect: dialect == 'unknown') and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 4) over depth, dialect, encoding, kind, mapping_status, operation, performed_by, required, translation_target, unavailable_behavior, universal_semantic', + {'fields': 'depth, dialect, encoding, kind, mapping_status, operation, performed_by, required, translation_target, unavailable_behavior, universal_semantic'}, + ) + if not ( + (((_c.descend(self, 'encoding', lambda encoding: _c.descend(encoding, 'depth', lambda depth: depth == 0) and _c.descend(encoding, 'kind', lambda kind: kind == 'none')) and _c.descend(self, 'mapping_status', lambda mapping_status: mapping_status in ['verified_universal', 'dialect_defined'])) and (_c.sent(self, 'performed_by') and _c.sent(self, 'translation_target'))) if (_c.descend(self, 'operation', lambda operation: operation == 'translate_to_native') and _c.sent(self, 'operation')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 5) over depth, encoding, kind, mapping_status, operation, performed_by, translation_target', + {'fields': 'depth, encoding, kind, mapping_status, operation, performed_by, translation_target'}, + ) + if not ( + ((not (_c.sent(self, 'translation_target')) and _c.descend(self, 'mapping_status', lambda mapping_status: mapping_status in ['verified_universal', 'dialect_defined']) and _c.sent(self, 'performed_by')) if (_c.descend(self, 'operation', lambda operation: operation == 'resolve_value') and _c.sent(self, 'operation')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 6) over mapping_status, operation, performed_by, translation_target', + {'fields': 'mapping_status, operation, performed_by, translation_target'}, + ) + if not ( + (_c.descend(self, 'performed_by', lambda performed_by: performed_by == 'request_executor') if ((_c.descend(self, 'dialect', lambda dialect: dialect in ['iab_vast', 'iab_daast']) and _c.descend(self, 'operation', lambda operation: operation == 'resolve_value')) and (_c.sent(self, 'dialect') and _c.sent(self, 'operation'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 7) over dialect, operation, performed_by', + {'fields': 'dialect, operation, performed_by'}, + ) + if not ( + ((not (_c.sent(self, 'performed_by') or _c.sent(self, 'translation_target')) and (_c.descend(self, 'encoding', lambda encoding: _c.descend(encoding, 'depth', lambda depth: depth == 0) and _c.descend(encoding, 'kind', lambda kind: kind == 'none')) and _c.descend(self, 'required', lambda required: required == False) and _c.descend(self, 'unavailable_behavior', lambda unavailable_behavior: unavailable_behavior == 'preserve'))) if (_c.descend(self, 'operation', lambda operation: operation == 'preserve') and _c.sent(self, 'operation')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 8) over depth, encoding, kind, operation, performed_by, required, translation_target, unavailable_behavior', + {'fields': 'depth, encoding, kind, operation, performed_by, required, translation_target, unavailable_behavior'}, + ) + if not ( + ((_c.descend(self, 'encoding', lambda encoding: _c.descend(encoding, 'depth', lambda depth: depth == 0) and _c.descend(encoding, 'kind', lambda kind: kind == 'none')) and _c.descend(self, 'operation', lambda operation: operation == 'preserve') and _c.descend(self, 'required', lambda required: required == False) and _c.descend(self, 'unavailable_behavior', lambda unavailable_behavior: unavailable_behavior == 'preserve')) if (_c.descend(self, 'location', lambda location: _c.descend(location, 'context', lambda context: context == 'opaque') and _c.sent(location, 'context')) and _c.sent(self, 'location')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 9) over context, depth, encoding, kind, location, operation, required, unavailable_behavior', + {'fields': 'context, depth, encoding, kind, location, operation, required, unavailable_behavior'}, + ) + if not ( + ((_c.descend(self, 'encoding', lambda encoding: _c.descend(encoding, 'depth', lambda depth: depth == 1) and _c.descend(encoding, 'kind', lambda kind: kind == 'rfc3986')) and _c.descend(self, 'location', lambda location: _c.descend(location, 'context', lambda context: context in ['url_query_value', 'url_path_segment'])) and _c.descend(self, 'operation', lambda operation: operation == 'resolve_value') and _c.descend(self, 'performed_by', lambda performed_by: performed_by == 'seller')) if (_c.descend(self, 'universal_semantic', lambda universal_semantic: universal_semantic in ['CATALOG_ID', 'SKU', 'GTIN', 'OFFERING_ID', 'JOB_ID', 'HOTEL_ID', 'FLIGHT_ID', 'VEHICLE_ID', 'LISTING_ID', 'STORE_ID', 'PROGRAM_ID', 'DESTINATION_ID', 'ITEM_NAME', 'ITEM_DESCRIPTION', 'ITEM_TAGLINE', 'ITEM_PRICE', 'ITEM_PRICE_CURRENCY']) and _c.sent(self, 'universal_semantic')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 10) over context, depth, encoding, kind, location, operation, performed_by, universal_semantic', + {'fields': 'context, depth, encoding, kind, location, operation, performed_by, universal_semantic'}, + ) + if not ( + (_c.descend(self, 'unavailable_behavior', lambda unavailable_behavior: unavailable_behavior == 'reject') if (_c.descend(self, 'required', lambda required: required == True) and _c.sent(self, 'required')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 11) over required, unavailable_behavior', + {'fields': 'required, unavailable_behavior'}, + ) + if not ( + ((_c.descend(self, 'location', lambda location: _c.descend(location, 'context', lambda context: context == 'url_query_value')) and _c.descend(self, 'operation', lambda operation: operation == 'resolve_value')) if (_c.descend(self, 'unavailable_behavior', lambda unavailable_behavior: unavailable_behavior == 'omit_parameter') and _c.sent(self, 'unavailable_behavior')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 12) over context, location, operation, unavailable_behavior', + {'fields': 'context, location, operation, unavailable_behavior'}, + ) + if not ( + (_c.descend(self, 'operation', lambda operation: operation == 'preserve') if (_c.descend(self, 'unavailable_behavior', lambda unavailable_behavior: unavailable_behavior == 'preserve') and _c.sent(self, 'unavailable_behavior')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 13) over operation, unavailable_behavior', + {'fields': 'operation, unavailable_behavior'}, + ) + if not ( + ((_c.descend(self, 'dialect', lambda dialect: dialect == 'iab_vast') and _c.descend(self, 'operation', lambda operation: operation == 'resolve_value') and _c.descend(self, 'required', lambda required: required == False)) if (_c.descend(self, 'unavailable_behavior', lambda unavailable_behavior: unavailable_behavior == 'dialect_sentinel') and _c.sent(self, 'unavailable_behavior')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroDeclaration violates a constraint its schema states outside its properties (core/macro-declaration.json, root conjunct 14) over dialect, operation, required, unavailable_behavior', + {'fields': 'dialect, operation, required, unavailable_behavior'}, + ) + return self diff --git a/src/adcp/types/domains/core/macro_encoding.py b/src/adcp/types/domains/core/macro_encoding.py index 6d378c82e..f614d4032 100644 --- a/src/adcp/types/domains/core/macro_encoding.py +++ b/src/adcp/types/domains/core/macro_encoding.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/macro_encoding.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class Kind(StrEnum): @@ -23,3 +29,37 @@ class MacroEncoding(AdCPBaseModel): ) kind: Kind depth: Annotated[SchemaInt, Field(ge=0)] + @model_validator(mode='after') + def _require_schema_required_group(self) -> MacroEncoding: + # Compiled from core/macro-encoding.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'depth', lambda depth: depth == 0) if (_c.descend(self, 'kind', lambda kind: kind == 'none') and _c.sent(self, 'kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroEncoding violates a constraint its schema states outside its properties (core/macro-encoding.json, root conjunct 0) over depth, kind', + {'fields': 'depth, kind'}, + ) + if not ( + (_c.descend(self, 'depth', lambda depth: _c.within(depth, ge=1)) if (_c.descend(self, 'kind', lambda kind: kind == 'rfc3986') and _c.sent(self, 'kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroEncoding violates a constraint its schema states outside its properties (core/macro-encoding.json, root conjunct 1) over depth, kind', + {'fields': 'depth, kind'}, + ) + if not ( + (_c.descend(self, 'depth', lambda depth: depth == 1) if (_c.descend(self, 'kind', lambda kind: kind == 'iab_vast_uri') and _c.sent(self, 'kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroEncoding violates a constraint its schema states outside its properties (core/macro-encoding.json, root conjunct 2) over depth, kind', + {'fields': 'depth, kind'}, + ) + return self diff --git a/src/adcp/types/domains/core/macro_resolution_capability.py b/src/adcp/types/domains/core/macro_resolution_capability.py index de4877035..14c94b85a 100644 --- a/src/adcp/types/domains/core/macro_resolution_capability.py +++ b/src/adcp/types/domains/core/macro_resolution_capability.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/macro_resolution_capability.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from pydantic import AnyUrl, ConfigDict, Field, model_validator from ..enums import macro_dialect, macro_resolver, macro_value_context, universal_macro from . import macro_encoding, macro_translation_target +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class MappingStatus(StrEnum): verified_universal = 'verified_universal' dialect_defined = 'dialect_defined' @@ -45,3 +51,85 @@ class MacroProcessingCapability(AdCPBaseModel): ), ] = None translation_target: macro_translation_target.MacroTranslationTarget | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> MacroProcessingCapability: + # Compiled from core/macro-resolution-capability.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'dialect_namespace') and _c.sent(self, 'dialect_revision')) if (_c.descend(self, 'dialect', lambda dialect: dialect in ['iab_vast', 'iab_daast', 'vendor']) and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroProcessingCapability violates a constraint its schema states outside its properties (core/macro-resolution-capability.json, root conjunct 0) over dialect, dialect_namespace, dialect_revision', + {'fields': 'dialect, dialect_namespace, dialect_revision'}, + ) + if not ( + (not (_c.sent(self, 'dialect_namespace') or _c.sent(self, 'dialect_revision')) if (_c.descend(self, 'dialect', lambda dialect: dialect in ['adcp', 'unknown']) and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroProcessingCapability violates a constraint its schema states outside its properties (core/macro-resolution-capability.json, root conjunct 1) over dialect, dialect_namespace, dialect_revision', + {'fields': 'dialect, dialect_namespace, dialect_revision'}, + ) + if not ( + (not (True) if (_c.descend(self, 'dialect', lambda dialect: dialect == 'unknown') and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroProcessingCapability violates a constraint its schema states outside its properties (core/macro-resolution-capability.json, root conjunct 2) over dialect', + {'fields': 'dialect'}, + ) + if not ( + (_c.sent(self, 'universal_semantic') if (_c.descend(self, 'mapping_status', lambda mapping_status: mapping_status == 'verified_universal') and _c.sent(self, 'mapping_status')) else not (_c.sent(self, 'universal_semantic'))) + ): + raise PydanticCustomError( + 'allOf', + 'MacroProcessingCapability violates a constraint its schema states outside its properties (core/macro-resolution-capability.json, root conjunct 3) over mapping_status, universal_semantic', + {'fields': 'mapping_status, universal_semantic'}, + ) + if not ( + (((_c.descend(self, 'dialect_semantic', lambda dialect_semantic: dialect_semantic in ['MEDIA_BUY_ID', 'PACKAGE_ID', 'CREATIVE_ID', 'CACHEBUSTER', 'TIMESTAMP', 'CLICK_URL', 'GDPR', 'GDPR_CONSENT', 'US_PRIVACY', 'GPP_STRING', 'GPP_SID', 'IP_ADDRESS', 'LIMIT_AD_TRACKING', 'DEVICE_TYPE', 'OS', 'OS_VERSION', 'DEVICE_MAKE', 'DEVICE_MODEL', 'USER_AGENT', 'APP_BUNDLE', 'APP_NAME', 'COUNTRY', 'REGION', 'CITY', 'ZIP', 'DMA', 'LAT', 'LONG', 'DEVICE_ID', 'DEVICE_ID_TYPE', 'DOMAIN', 'PAGE_URL', 'REFERRER', 'KEYWORDS', 'PLACEMENT_ID', 'FOLD_POSITION', 'AD_WIDTH', 'AD_HEIGHT', 'VIDEO_ID', 'VIDEO_TITLE', 'VIDEO_DURATION', 'VIDEO_CATEGORY', 'CONTENT_GENRE', 'CONTENT_RATING', 'PLAYER_WIDTH', 'PLAYER_HEIGHT', 'POD_POSITION', 'POD_SIZE', 'AD_BREAK_ID', 'STATION_ID', 'COLLECTION_NAME', 'INSTALLMENT_ID', 'AUDIO_DURATION', 'TMPX', 'IMPRESSION_ID', 'AXEM', 'CATALOG_ID', 'SKU', 'GTIN', 'OFFERING_ID', 'JOB_ID', 'HOTEL_ID', 'FLIGHT_ID', 'VEHICLE_ID', 'LISTING_ID', 'STORE_ID', 'PROGRAM_ID', 'DESTINATION_ID', 'CREATIVE_VARIANT_ID', 'APP_ITEM_ID', 'ITEM_NAME', 'ITEM_DESCRIPTION', 'ITEM_TAGLINE', 'ITEM_PRICE', 'ITEM_PRICE_CURRENCY']) and _c.descend(self, 'mapping_status', lambda mapping_status: mapping_status == 'verified_universal')) and _c.sent(self, 'universal_semantic')) if (_c.descend(self, 'dialect', lambda dialect: dialect == 'adcp') and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroProcessingCapability violates a constraint its schema states outside its properties (core/macro-resolution-capability.json, root conjunct 4) over dialect, dialect_semantic, mapping_status, universal_semantic', + {'fields': 'dialect, dialect_semantic, mapping_status, universal_semantic'}, + ) + if not ( + ((_c.descend(self, 'operation', lambda operation: operation == 'resolve_value') and _c.descend(self, 'performed_by', lambda performed_by: performed_by == 'seller') and _c.descend(self, 'supported_contexts', lambda supported_contexts: _c.every(supported_contexts, lambda item: item in ['url_query_value', 'url_path_segment'])) and _c.descend(self, 'supported_encodings', lambda supported_encodings: _c.every(supported_encodings, lambda item: (_c.descend(item, 'depth', lambda depth: depth == 1) and _c.descend(item, 'kind', lambda kind: kind == 'rfc3986')) and (_c.sent(item, 'kind') and _c.sent(item, 'depth'))))) if (_c.descend(self, 'universal_semantic', lambda universal_semantic: universal_semantic in ['CATALOG_ID', 'SKU', 'GTIN', 'OFFERING_ID', 'JOB_ID', 'HOTEL_ID', 'FLIGHT_ID', 'VEHICLE_ID', 'LISTING_ID', 'STORE_ID', 'PROGRAM_ID', 'DESTINATION_ID', 'ITEM_NAME', 'ITEM_DESCRIPTION', 'ITEM_TAGLINE', 'ITEM_PRICE', 'ITEM_PRICE_CURRENCY']) and _c.sent(self, 'universal_semantic')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroProcessingCapability violates a constraint its schema states outside its properties (core/macro-resolution-capability.json, root conjunct 5) over depth, kind, operation, performed_by, supported_contexts, supported_encodings, universal_semantic', + {'fields': 'depth, kind, operation, performed_by, supported_contexts, supported_encodings, universal_semantic'}, + ) + if not ( + ((not (_c.sent(self, 'translation_target')) and _c.sent(self, 'supported_encodings')) if (_c.descend(self, 'operation', lambda operation: operation == 'resolve_value') and _c.sent(self, 'operation')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroProcessingCapability violates a constraint its schema states outside its properties (core/macro-resolution-capability.json, root conjunct 6) over operation, supported_encodings, translation_target', + {'fields': 'operation, supported_encodings, translation_target'}, + ) + if not ( + (_c.descend(self, 'performed_by', lambda performed_by: performed_by == 'request_executor') if ((_c.descend(self, 'dialect', lambda dialect: dialect in ['iab_vast', 'iab_daast']) and _c.descend(self, 'operation', lambda operation: operation == 'resolve_value')) and (_c.sent(self, 'dialect') and _c.sent(self, 'operation'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroProcessingCapability violates a constraint its schema states outside its properties (core/macro-resolution-capability.json, root conjunct 7) over dialect, operation, performed_by', + {'fields': 'dialect, operation, performed_by'}, + ) + if not ( + ((not (_c.sent(self, 'supported_encodings')) and _c.sent(self, 'translation_target')) if (_c.descend(self, 'operation', lambda operation: operation == 'translate_to_native') and _c.sent(self, 'operation')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroProcessingCapability violates a constraint its schema states outside its properties (core/macro-resolution-capability.json, root conjunct 8) over operation, supported_encodings, translation_target', + {'fields': 'operation, supported_encodings, translation_target'}, + ) + return self diff --git a/src/adcp/types/domains/core/macro_resolution_result.py b/src/adcp/types/domains/core/macro_resolution_result.py index 5529952a4..99bd540dc 100644 --- a/src/adcp/types/domains/core/macro_resolution_result.py +++ b/src/adcp/types/domains/core/macro_resolution_result.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/macro_resolution_result.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from pydantic import AnyUrl, ConfigDict, Field, StrictBool, model_validator from ..enums import ( macro_dialect, @@ -21,6 +21,12 @@ from . import macro_encoding +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class UnavailableBehavior(StrEnum): preserve = 'preserve' omit_parameter = 'omit_parameter' @@ -62,3 +68,117 @@ class MacroResolutionResult(AdCPBaseModel): Field(description='Exact advertised profiles considered for this match, when relevant.'), ] = None message: Annotated[str | None, Field(min_length=1)] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> MacroResolutionResult: + # Compiled from core/macro-resolution-result.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'dialect_namespace') and _c.sent(self, 'dialect_revision')) if (_c.descend(self, 'dialect', lambda dialect: dialect in ['iab_vast', 'iab_daast', 'vendor']) and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 0) over dialect, dialect_namespace, dialect_revision', + {'fields': 'dialect, dialect_namespace, dialect_revision'}, + ) + if not ( + (not (_c.sent(self, 'dialect_namespace') or _c.sent(self, 'dialect_revision')) if (_c.descend(self, 'dialect', lambda dialect: dialect in ['adcp', 'unknown']) and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 1) over dialect, dialect_namespace, dialect_revision', + {'fields': 'dialect, dialect_namespace, dialect_revision'}, + ) + if not ( + (_c.sent(self, 'universal_semantic') if (_c.descend(self, 'mapping_status', lambda mapping_status: mapping_status == 'verified_universal') and _c.sent(self, 'mapping_status')) else not (_c.sent(self, 'universal_semantic'))) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 2) over mapping_status, universal_semantic', + {'fields': 'mapping_status, universal_semantic'}, + ) + if not ( + (_c.sent(self, 'performed_by') if (_c.descend(self, 'operation', lambda operation: operation in ['translate_to_native', 'resolve_value']) and _c.sent(self, 'operation')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 3) over operation, performed_by', + {'fields': 'operation, performed_by'}, + ) + if not ( + (_c.descend(self, 'performed_by', lambda performed_by: performed_by == 'request_executor') if ((_c.descend(self, 'dialect', lambda dialect: dialect in ['iab_vast', 'iab_daast']) and _c.descend(self, 'operation', lambda operation: operation == 'resolve_value')) and (_c.sent(self, 'dialect') and _c.sent(self, 'operation'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 4) over dialect, operation, performed_by', + {'fields': 'dialect, operation, performed_by'}, + ) + if not ( + (((_c.descend(self, 'dialect_semantic', lambda dialect_semantic: dialect_semantic in ['MEDIA_BUY_ID', 'PACKAGE_ID', 'CREATIVE_ID', 'CACHEBUSTER', 'TIMESTAMP', 'CLICK_URL', 'GDPR', 'GDPR_CONSENT', 'US_PRIVACY', 'GPP_STRING', 'GPP_SID', 'IP_ADDRESS', 'LIMIT_AD_TRACKING', 'DEVICE_TYPE', 'OS', 'OS_VERSION', 'DEVICE_MAKE', 'DEVICE_MODEL', 'USER_AGENT', 'APP_BUNDLE', 'APP_NAME', 'COUNTRY', 'REGION', 'CITY', 'ZIP', 'DMA', 'LAT', 'LONG', 'DEVICE_ID', 'DEVICE_ID_TYPE', 'DOMAIN', 'PAGE_URL', 'REFERRER', 'KEYWORDS', 'PLACEMENT_ID', 'FOLD_POSITION', 'AD_WIDTH', 'AD_HEIGHT', 'VIDEO_ID', 'VIDEO_TITLE', 'VIDEO_DURATION', 'VIDEO_CATEGORY', 'CONTENT_GENRE', 'CONTENT_RATING', 'PLAYER_WIDTH', 'PLAYER_HEIGHT', 'POD_POSITION', 'POD_SIZE', 'AD_BREAK_ID', 'STATION_ID', 'COLLECTION_NAME', 'INSTALLMENT_ID', 'AUDIO_DURATION', 'TMPX', 'IMPRESSION_ID', 'AXEM', 'CATALOG_ID', 'SKU', 'GTIN', 'OFFERING_ID', 'JOB_ID', 'HOTEL_ID', 'FLIGHT_ID', 'VEHICLE_ID', 'LISTING_ID', 'STORE_ID', 'PROGRAM_ID', 'DESTINATION_ID', 'CREATIVE_VARIANT_ID', 'APP_ITEM_ID', 'ITEM_NAME', 'ITEM_DESCRIPTION', 'ITEM_TAGLINE', 'ITEM_PRICE', 'ITEM_PRICE_CURRENCY']) and _c.descend(self, 'mapping_status', lambda mapping_status: mapping_status == 'verified_universal')) and _c.sent(self, 'universal_semantic')) if (_c.descend(self, 'dialect', lambda dialect: dialect == 'adcp') and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 5) over dialect, dialect_semantic, mapping_status, universal_semantic', + {'fields': 'dialect, dialect_semantic, mapping_status, universal_semantic'}, + ) + if not ( + ((not (_c.sent(self, 'performed_by') or _c.sent(self, 'universal_semantic')) and (_c.descend(self, 'mapping_status', lambda mapping_status: mapping_status == 'unresolved') and _c.descend(self, 'operation', lambda operation: operation == 'preserve') and _c.descend(self, 'reason', lambda reason: reason == 'preserved_unknown') and _c.descend(self, 'requested_encoding', lambda requested_encoding: _c.descend(requested_encoding, 'depth', lambda depth: depth == 0) and _c.descend(requested_encoding, 'kind', lambda kind: kind == 'none')) and _c.descend(self, 'required', lambda required: required == False) and _c.descend(self, 'status', lambda status: status == 'preserved_for_downstream') and _c.descend(self, 'unavailable_behavior', lambda unavailable_behavior: unavailable_behavior == 'preserve'))) if (_c.descend(self, 'dialect', lambda dialect: dialect == 'unknown') and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 6) over depth, dialect, kind, mapping_status, operation, performed_by, reason, requested_encoding, required, status, unavailable_behavior, universal_semantic', + {'fields': 'depth, dialect, kind, mapping_status, operation, performed_by, reason, requested_encoding, required, status, unavailable_behavior, universal_semantic'}, + ) + if not ( + ((not (_c.sent(self, 'performed_by')) and (_c.descend(self, 'requested_encoding', lambda requested_encoding: _c.descend(requested_encoding, 'depth', lambda depth: depth == 0) and _c.descend(requested_encoding, 'kind', lambda kind: kind == 'none')) and _c.descend(self, 'required', lambda required: required == False) and _c.descend(self, 'status', lambda status: status == 'preserved_for_downstream') and _c.descend(self, 'unavailable_behavior', lambda unavailable_behavior: unavailable_behavior == 'preserve'))) if (_c.descend(self, 'operation', lambda operation: operation == 'preserve') and _c.sent(self, 'operation')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 7) over depth, kind, operation, performed_by, requested_encoding, required, status, unavailable_behavior', + {'fields': 'depth, kind, operation, performed_by, requested_encoding, required, status, unavailable_behavior'}, + ) + if not ( + (_c.descend(self, 'unavailable_behavior', lambda unavailable_behavior: unavailable_behavior == 'reject') if (_c.descend(self, 'required', lambda required: required == True) and _c.sent(self, 'required')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 8) over required, unavailable_behavior', + {'fields': 'required, unavailable_behavior'}, + ) + if not ( + (_c.descend(self, 'reason', lambda reason: reason == 'capability_match') if (_c.descend(self, 'status', lambda status: status == 'resolvable') and _c.sent(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 9) over reason, status', + {'fields': 'reason, status'}, + ) + if not ( + ((not (_c.sent(self, 'performed_by')) and (_c.descend(self, 'operation', lambda operation: operation == 'preserve') and _c.descend(self, 'reason', lambda reason: reason in ['preserved_unknown', 'preserved_for_downstream']))) if (_c.descend(self, 'status', lambda status: status == 'preserved_for_downstream') and _c.sent(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 10) over operation, performed_by, reason, status', + {'fields': 'operation, performed_by, reason, status'}, + ) + if not ( + (_c.descend(self, 'reason', lambda reason: reason in ['dialect_unsupported', 'namespace_mismatch', 'revision_mismatch', 'semantic_unsupported', 'operation_unsupported', 'resolver_mismatch', 'context_unsupported', 'encoding_unsupported']) if (_c.descend(self, 'status', lambda status: status == 'unsupported') and _c.sent(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 11) over reason, status', + {'fields': 'reason, status'}, + ) + if not ( + (_c.descend(self, 'reason', lambda reason: reason == 'ambiguous_mapping') if (_c.descend(self, 'status', lambda status: status == 'ambiguous') and _c.sent(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroResolutionResult violates a constraint its schema states outside its properties (core/macro-resolution-result.json, root conjunct 12) over reason, status', + {'fields': 'reason, status'}, + ) + return self diff --git a/src/adcp/types/domains/core/macro_translation_target.py b/src/adcp/types/domains/core/macro_translation_target.py index e3d4143ed..a36128537 100644 --- a/src/adcp/types/domains/core/macro_translation_target.py +++ b/src/adcp/types/domains/core/macro_translation_target.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: core/macro_translation_target.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from pydantic import AnyUrl, ConfigDict, Field, model_validator from ..enums import macro_dialect, macro_mapping_status, macro_resolver, universal_macro from . import macro_encoding +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class MacroTranslationTarget(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -27,3 +33,69 @@ class MacroTranslationTarget(AdCPBaseModel): next_operation: Literal['resolve_value'] = 'resolve_value' performed_by: macro_resolver.MacroProcessingActor encoding: macro_encoding.MacroEncoding + @model_validator(mode='after') + def _require_schema_required_group(self) -> MacroTranslationTarget: + # Compiled from core/macro-translation-target.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'dialect_namespace') and _c.sent(self, 'dialect_revision')) if (_c.descend(self, 'dialect', lambda dialect: dialect in ['iab_vast', 'iab_daast', 'vendor']) and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroTranslationTarget violates a constraint its schema states outside its properties (core/macro-translation-target.json, root conjunct 0) over dialect, dialect_namespace, dialect_revision', + {'fields': 'dialect, dialect_namespace, dialect_revision'}, + ) + if not ( + (not (_c.sent(self, 'dialect_namespace') or _c.sent(self, 'dialect_revision')) if (_c.descend(self, 'dialect', lambda dialect: dialect in ['adcp', 'unknown']) and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroTranslationTarget violates a constraint its schema states outside its properties (core/macro-translation-target.json, root conjunct 1) over dialect, dialect_namespace, dialect_revision', + {'fields': 'dialect, dialect_namespace, dialect_revision'}, + ) + if not ( + (not (True) if (_c.descend(self, 'dialect', lambda dialect: dialect == 'unknown') and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroTranslationTarget violates a constraint its schema states outside its properties (core/macro-translation-target.json, root conjunct 2) over dialect', + {'fields': 'dialect'}, + ) + if not ( + (_c.sent(self, 'universal_semantic') if (_c.descend(self, 'mapping_status', lambda mapping_status: mapping_status == 'verified_universal') and _c.sent(self, 'mapping_status')) else not (_c.sent(self, 'universal_semantic'))) + ): + raise PydanticCustomError( + 'allOf', + 'MacroTranslationTarget violates a constraint its schema states outside its properties (core/macro-translation-target.json, root conjunct 3) over mapping_status, universal_semantic', + {'fields': 'mapping_status, universal_semantic'}, + ) + if not ( + (_c.descend(self, 'performed_by', lambda performed_by: performed_by == 'request_executor') if (_c.descend(self, 'dialect', lambda dialect: dialect in ['iab_vast', 'iab_daast']) and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroTranslationTarget violates a constraint its schema states outside its properties (core/macro-translation-target.json, root conjunct 4) over dialect, performed_by', + {'fields': 'dialect, performed_by'}, + ) + if not ( + (((_c.descend(self, 'dialect_semantic', lambda dialect_semantic: dialect_semantic in ['MEDIA_BUY_ID', 'PACKAGE_ID', 'CREATIVE_ID', 'CACHEBUSTER', 'TIMESTAMP', 'CLICK_URL', 'GDPR', 'GDPR_CONSENT', 'US_PRIVACY', 'GPP_STRING', 'GPP_SID', 'IP_ADDRESS', 'LIMIT_AD_TRACKING', 'DEVICE_TYPE', 'OS', 'OS_VERSION', 'DEVICE_MAKE', 'DEVICE_MODEL', 'USER_AGENT', 'APP_BUNDLE', 'APP_NAME', 'COUNTRY', 'REGION', 'CITY', 'ZIP', 'DMA', 'LAT', 'LONG', 'DEVICE_ID', 'DEVICE_ID_TYPE', 'DOMAIN', 'PAGE_URL', 'REFERRER', 'KEYWORDS', 'PLACEMENT_ID', 'FOLD_POSITION', 'AD_WIDTH', 'AD_HEIGHT', 'VIDEO_ID', 'VIDEO_TITLE', 'VIDEO_DURATION', 'VIDEO_CATEGORY', 'CONTENT_GENRE', 'CONTENT_RATING', 'PLAYER_WIDTH', 'PLAYER_HEIGHT', 'POD_POSITION', 'POD_SIZE', 'AD_BREAK_ID', 'STATION_ID', 'COLLECTION_NAME', 'INSTALLMENT_ID', 'AUDIO_DURATION', 'TMPX', 'IMPRESSION_ID', 'AXEM', 'CATALOG_ID', 'SKU', 'GTIN', 'OFFERING_ID', 'JOB_ID', 'HOTEL_ID', 'FLIGHT_ID', 'VEHICLE_ID', 'LISTING_ID', 'STORE_ID', 'PROGRAM_ID', 'DESTINATION_ID', 'CREATIVE_VARIANT_ID', 'APP_ITEM_ID', 'ITEM_NAME', 'ITEM_DESCRIPTION', 'ITEM_TAGLINE', 'ITEM_PRICE', 'ITEM_PRICE_CURRENCY']) and _c.descend(self, 'mapping_status', lambda mapping_status: mapping_status == 'verified_universal')) and _c.sent(self, 'universal_semantic')) if (_c.descend(self, 'dialect', lambda dialect: dialect == 'adcp') and _c.sent(self, 'dialect')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroTranslationTarget violates a constraint its schema states outside its properties (core/macro-translation-target.json, root conjunct 5) over dialect, dialect_semantic, mapping_status, universal_semantic', + {'fields': 'dialect, dialect_semantic, mapping_status, universal_semantic'}, + ) + if not ( + ((_c.descend(self, 'encoding', lambda encoding: _c.descend(encoding, 'depth', lambda depth: depth == 1) and _c.descend(encoding, 'kind', lambda kind: kind == 'rfc3986')) and _c.descend(self, 'performed_by', lambda performed_by: performed_by == 'seller')) if (_c.descend(self, 'universal_semantic', lambda universal_semantic: universal_semantic in ['CATALOG_ID', 'SKU', 'GTIN', 'OFFERING_ID', 'JOB_ID', 'HOTEL_ID', 'FLIGHT_ID', 'VEHICLE_ID', 'LISTING_ID', 'STORE_ID', 'PROGRAM_ID', 'DESTINATION_ID', 'ITEM_NAME', 'ITEM_DESCRIPTION', 'ITEM_TAGLINE', 'ITEM_PRICE', 'ITEM_PRICE_CURRENCY']) and _c.sent(self, 'universal_semantic')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MacroTranslationTarget violates a constraint its schema states outside its properties (core/macro-translation-target.json, root conjunct 6) over depth, encoding, kind, performed_by, universal_semantic', + {'fields': 'depth, encoding, kind, performed_by, universal_semantic'}, + ) + return self diff --git a/src/adcp/types/domains/core/mcp_webhook_payload.py b/src/adcp/types/domains/core/mcp_webhook_payload.py index 9fad1fac0..ea7e8400f 100644 --- a/src/adcp/types/domains/core/mcp_webhook_payload.py +++ b/src/adcp/types/domains/core/mcp_webhook_payload.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: core/mcp_webhook_payload.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..enums import adcp_protocol, task_status from ..enums import task_type as task_type_1 from . import async_response_data +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class McpWebhookPayload(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -98,3 +104,21 @@ class McpWebhookPayload(AdCPBaseModel): description='Task-specific payload matching the status. For completed/failed, contains the full task response. For working/input-required/submitted, contains status-specific data. This is the data layer that AdCP specs - same structure used in A2A status.message.parts[].data.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> McpWebhookPayload: + # Compiled from core/mcp-webhook-payload.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'result', lambda result: (((_c.descend(result, 'adcp_major_version', lambda adcp_major_version: _c.within(adcp_major_version, le=99) and _c.within(adcp_major_version, ge=1) and _c.json_type(adcp_major_version, 'integer')) and _c.descend(result, 'adcp_version', lambda adcp_version: _c.matches(adcp_version, '^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$') and _c.json_type(adcp_version, 'string'))) and _c.json_type(result, 'object')) and (not (_c.supplied(result, 'task_status') or _c.supplied(result, 'response_status')) and (_c.descend(result, 'adcp_error', lambda adcp_error: (not (_c.supplied(adcp_error, 'buyer_reason')) or _c.supplied(adcp_error, 'recovery')) and (_c.descend(adcp_error, 'buyer_reason', lambda buyer_reason: (_c.descend(buyer_reason, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(buyer_reason, 'message', lambda message: _c.length(message, minimum=1) and _c.json_type(message, 'string'))) and (_c.supplied(buyer_reason, 'code') and _c.supplied(buyer_reason, 'message')) and _c.json_type(buyer_reason, 'object')) and _c.descend(adcp_error, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(adcp_error, 'details', lambda details: _c.json_type(details, 'object')) and _c.descend(adcp_error, 'field', lambda field: _c.json_type(field, 'string')) and _c.descend(adcp_error, 'issues', lambda issues: _c.every(issues, lambda item: (_c.descend(item, 'discriminator', lambda discriminator: _c.every(discriminator, lambda item: _c.only(item, ['property_name', 'value']) and (_c.descend(item, 'property_name', lambda property_name: _c.json_type(property_name, 'string')) and _c.descend(item, 'value', lambda value: _c.json_type(value, 'string', 'number', 'boolean', 'null'))) and (_c.supplied(item, 'property_name') and _c.supplied(item, 'value')) and _c.json_type(item, 'object')) and _c.json_type(discriminator, 'array')) and _c.descend(item, 'keyword', lambda keyword: _c.json_type(keyword, 'string')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'pointer', lambda pointer: _c.json_type(pointer, 'string')) and _c.descend(item, 'schemaPath', lambda schemaPath: _c.json_type(schemaPath, 'string')) and _c.descend(item, 'schema_id', lambda schema_id: _c.json_type(schema_id, 'string'))) and (_c.supplied(item, 'pointer') and _c.supplied(item, 'message') and _c.supplied(item, 'keyword')) and _c.json_type(item, 'object')) and _c.json_type(issues, 'array')) and _c.descend(adcp_error, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(adcp_error, 'recovery', lambda recovery: recovery in ['transient', 'correctable', 'terminal'] and _c.json_type(recovery, 'string')) and _c.descend(adcp_error, 'retry_after', lambda retry_after: _c.within(retry_after, le=3600) and _c.within(retry_after, ge=1) and _c.json_type(retry_after, 'number')) and _c.descend(adcp_error, 'sdk_id', lambda sdk_id: _c.json_type(sdk_id, 'string')) and _c.descend(adcp_error, 'source', lambda source: source in ['producer', 'sdk'] and _c.json_type(source, 'string')) and _c.descend(adcp_error, 'suggestion', lambda suggestion: _c.json_type(suggestion, 'string'))) and (_c.supplied(adcp_error, 'code') and _c.supplied(adcp_error, 'message')) and _c.json_type(adcp_error, 'object')) and _c.descend(result, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(result, 'context_id', lambda context_id: _c.json_type(context_id, 'string')) and _c.descend(result, 'governance_context', lambda governance_context: _c.length(governance_context, maximum=4096) and _c.length(governance_context, minimum=1) and _c.matches(governance_context, '^[\\x20-\\x7E]+$') and _c.json_type(governance_context, 'string')) and _c.descend(result, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(result, 'payload', lambda payload: _c.json_type(payload, 'object')) and _c.descend(result, 'push_notification_config', lambda push_notification_config: (_c.descend(push_notification_config, 'authentication', lambda authentication: _c.only(authentication, ['credentials', 'schemes']) and (_c.descend(authentication, 'credentials', lambda credentials: _c.length(credentials, minimum=32) and _c.json_type(credentials, 'string')) and _c.descend(authentication, 'schemes', lambda schemes: _c.every(schemes, lambda item: item in ['Bearer', 'HMAC-SHA256'] and _c.json_type(item, 'string')) and _c.length(schemes, maximum=1) and _c.length(schemes, minimum=1) and _c.json_type(schemes, 'array'))) and (_c.supplied(authentication, 'schemes') and _c.supplied(authentication, 'credentials')) and _c.json_type(authentication, 'object')) and _c.descend(push_notification_config, 'operation_id', lambda operation_id: _c.length(operation_id, maximum=255) and _c.length(operation_id, minimum=1) and _c.matches(operation_id, '^[A-Za-z0-9_.:-]{1,255}$') and _c.json_type(operation_id, 'string')) and _c.descend(push_notification_config, 'token', lambda token: _c.length(token, maximum=4096) and _c.length(token, minimum=16) and _c.json_type(token, 'string')) and _c.descend(push_notification_config, 'url', lambda url: _c.json_type(url, 'string'))) and _c.supplied(push_notification_config, 'url') and _c.json_type(push_notification_config, 'object')) and _c.descend(result, 'replayed', lambda replayed: _c.json_type(replayed, 'boolean')) and _c.descend(result, 'status', lambda status: status in ['submitted', 'working', 'input-required', 'completed', 'canceled', 'failed', 'rejected', 'auth-required', 'unknown'] and _c.json_type(status, 'string')) and _c.descend(result, 'task_id', lambda task_id: _c.json_type(task_id, 'string')) and _c.descend(result, 'timestamp', lambda timestamp: _c.json_type(timestamp, 'string'))) and _c.sent(result, 'status') and _c.json_type(result, 'object'))) and not (_c.sent(result, 'products') or _c.sent(result, 'proposals') or _c.sent(result, 'errors') or _c.supplied(result, 'adcp_error') or _c.sent(result, 'incomplete') or _c.sent(result, 'cache_scope') or _c.sent(result, 'filter_diagnostics') or _c.sent(result, 'refinement_applied') or _c.sent(result, 'unchanged') or _c.sent(result, 'wholesale_feed_version') or _c.sent(result, 'pricing_version') or _c.sent(result, 'pagination')) and (_c.descend(result, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(result, 'ext', lambda ext: _c.json_type(ext, 'object')) and _c.descend(result, 'reason', lambda reason: _c.length(reason, maximum=2000) and _c.length(reason, minimum=1) and _c.json_type(reason, 'string')) and _c.descend(result, 'status', lambda status: status == 'rejected' and _c.json_type(status, 'string')) and _c.descend(result, 'suggestions', lambda suggestions: _c.every(suggestions, lambda item: _c.length(item, maximum=1000) and _c.length(item, minimum=1) and _c.json_type(item, 'string')) and _c.length(suggestions, maximum=20) and _c.length(suggestions, minimum=1) and _c.json_type(suggestions, 'array'))) and (_c.sent(result, 'status') and _c.sent(result, 'reason')) and _c.json_type(result, 'object')) if ((_c.descend(self, 'result', lambda result: _c.descend(result, 'status', lambda status: status == 'rejected') and _c.sent(result, 'status')) and _c.descend(self, 'task_type', lambda task_type: task_type == 'get_products')) and (_c.sent(self, 'task_type') and _c.sent(self, 'result'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'McpWebhookPayload violates a constraint its schema states outside its properties (core/mcp-webhook-payload.json, root conjunct 0) over adcp_error, adcp_major_version, adcp_version, authentication, buyer_reason, cache_scope, code, context, context_id, credentials, details, discriminator, errors, ext, field, filter_diagnostics, governance_context, incomplete, issues, keyword, message, operation_id, pagination, payload, pointer, pricing_version, products, property_name, proposals, push_notification_config, reason, recovery, refinement_applied, replayed, response_status, result, retry_after, schemaPath, schema_id, schemes, sdk_id, source, status, suggestion, suggestions, task_id, task_status, task_type, timestamp, token, unchanged, url, value, wholesale_feed_version', + {'fields': 'adcp_error, adcp_major_version, adcp_version, authentication, buyer_reason, cache_scope, code, context, context_id, credentials, details, discriminator, errors, ext, field, filter_diagnostics, governance_context, incomplete, issues, keyword, message, operation_id, pagination, payload, pointer, pricing_version, products, property_name, proposals, push_notification_config, reason, recovery, refinement_applied, replayed, response_status, result, retry_after, schemaPath, schema_id, schemes, sdk_id, source, status, suggestion, suggestions, task_id, task_status, task_type, timestamp, token, unchanged, url, value, wholesale_feed_version'}, + ) + return self diff --git a/src/adcp/types/domains/core/media_buy.py b/src/adcp/types/domains/core/media_buy.py index 0fc9af67a..148eb9d51 100644 --- a/src/adcp/types/domains/core/media_buy.py +++ b/src/adcp/types/domains/core/media_buy.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/media_buy.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..enums import canceled_by as canceled_by_1 from ..enums import media_buy_health, media_buy_status @@ -21,6 +21,12 @@ from . import impairment, media_buy_frequency_cap, package +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Cancellation(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -176,3 +182,39 @@ class MediaBuy(AdCPBaseModel): created_at: Annotated[AwareDatetime | None, Field(description='Creation timestamp')] = None updated_at: Annotated[AwareDatetime | None, Field(description='Last update timestamp')] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> MediaBuy: + # Compiled from core/media-buy.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'accepted_proposal_id')) + or _c.supplied(self, 'accepted_proposal_terms_digest') + ): + raise PydanticCustomError( + 'allOf', + 'MediaBuy violates a constraint its schema states outside its properties (core/media-buy.json, root conjunct 0) over accepted_proposal_id, accepted_proposal_terms_digest', + {'fields': 'accepted_proposal_id, accepted_proposal_terms_digest'}, + ) + if not ( + not (_c.supplied(self, 'accepted_proposal_terms_digest')) + or _c.supplied(self, 'accepted_proposal_id') + ): + raise PydanticCustomError( + 'allOf', + 'MediaBuy violates a constraint its schema states outside its properties (core/media-buy.json, root conjunct 1) over accepted_proposal_id, accepted_proposal_terms_digest', + {'fields': 'accepted_proposal_id, accepted_proposal_terms_digest'}, + ) + if not ( + ((not (_c.descend(self, 'status', lambda status: status == 'active') and _c.sent(self, 'status')) and _c.descend(self, 'packages', lambda packages: _c.every(packages, lambda item: not (_c.sent(item, 'committed_metrics'))))) if (_c.descend(self, 'confirmed_at', lambda confirmed_at: _c.json_type(confirmed_at, 'null')) and _c.supplied(self, 'confirmed_at')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'MediaBuy violates a constraint its schema states outside its properties (core/media-buy.json, root conjunct 2) over committed_metrics, confirmed_at, packages, status', + {'fields': 'committed_metrics, confirmed_at, packages, status'}, + ) + return self diff --git a/src/adcp/types/domains/core/media_buy_features.py b/src/adcp/types/domains/core/media_buy_features.py index f41afaf1b..6cb55c528 100644 --- a/src/adcp/types/domains/core/media_buy_features.py +++ b/src/adcp/types/domains/core/media_buy_features.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/media_buy_features.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -78,3 +78,9 @@ class MediaBuyFeatures(AdCPBaseModel): description='Structured support for canonical bidding by authored scope, allocation context, mode, strength, and strength-qualified multi-field combination. Presence does not imply support for both scopes, both allocation modes, or every policy shape. Sellers MUST preserve every advertised semantic exactly and reject unadvertised policies rather than translating them.' ), ] = None + canonical_creatives: Annotated[ + StrictBool | None, + Field( + description='Advertises canonical creative identity on AdCP 3.1. AdCP 3.2+ is canonical by contract. Published after the bundled capabilities schema; negotiation must not silently discard this evidence.' + ), + ] = None diff --git a/src/adcp/types/domains/core/media_buy_frequency_cap_capability.py b/src/adcp/types/domains/core/media_buy_frequency_cap_capability.py index fd09c87c4..6d6599f92 100644 --- a/src/adcp/types/domains/core/media_buy_frequency_cap_capability.py +++ b/src/adcp/types/domains/core/media_buy_frequency_cap_capability.py @@ -1,17 +1,23 @@ # generated by datamodel-codegen: # filename: core/media_buy_frequency_cap_capability.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Any -from pydantic import Field +from pydantic import Field, model_validator from ..enums import media_buy_frequency_cap_control_mode from .frequency_cap_constraints import FrequencyCapConstraints +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class MediaBuyFrequencyCapCapability(FrequencyCapConstraints): supported_control_modes: Annotated[ list[media_buy_frequency_cap_control_mode.MediaBuyFrequencyCapControlMode], @@ -23,3 +29,21 @@ class MediaBuyFrequencyCapCapability(FrequencyCapConstraints): supported_per_units: Any max_impressions_constraints: Any window_constraints: Any + @model_validator(mode='after') + def _require_schema_required_group(self) -> MediaBuyFrequencyCapCapability: + # Compiled from core/media-buy-frequency-cap-capability.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'suppression_constraints')) + ): + raise PydanticCustomError( + 'allOf', + 'MediaBuyFrequencyCapCapability violates a constraint its schema states outside its properties (core/media-buy-frequency-cap-capability.json, root conjunct 0) over suppression_constraints', + {'fields': 'suppression_constraints'}, + ) + return self diff --git a/src/adcp/types/domains/core/media_buy_frequency_cap_requirement.py b/src/adcp/types/domains/core/media_buy_frequency_cap_requirement.py index 620276de3..1f8ae0879 100644 --- a/src/adcp/types/domains/core/media_buy_frequency_cap_requirement.py +++ b/src/adcp/types/domains/core/media_buy_frequency_cap_requirement.py @@ -1,19 +1,43 @@ # generated by datamodel-codegen: # filename: core/media_buy_frequency_cap_requirement.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated -from pydantic import Field +from pydantic import Field, model_validator from ..enums import media_buy_frequency_cap_control_mode from .frequency_cap_requirements import FrequencyCapRequirements +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class MediaBuyFrequencyCapRequirement(FrequencyCapRequirements): supported_control_modes: Annotated[ list[media_buy_frequency_cap_control_mode.MediaBuyFrequencyCapControlMode] | None, Field(min_length=1), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> MediaBuyFrequencyCapRequirement: + # Compiled from core/media-buy-frequency-cap-requirement.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'supported_suppression_units')) + ): + raise PydanticCustomError( + 'allOf', + 'MediaBuyFrequencyCapRequirement violates a constraint its schema states outside its properties (core/media-buy-frequency-cap-requirement.json, root conjunct 0) over supported_suppression_units', + {'fields': 'supported_suppression_units'}, + ) + return self diff --git a/src/adcp/types/domains/core/media_buy_frequency_cap_support.py b/src/adcp/types/domains/core/media_buy_frequency_cap_support.py index c8c103793..3c5626246 100644 --- a/src/adcp/types/domains/core/media_buy_frequency_cap_support.py +++ b/src/adcp/types/domains/core/media_buy_frequency_cap_support.py @@ -1,17 +1,23 @@ # generated by datamodel-codegen: # filename: core/media_buy_frequency_cap_support.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated -from pydantic import Field +from pydantic import Field, model_validator from ..enums import media_buy_frequency_cap_control_mode from .frequency_cap_constraints import FrequencyCapConstraints +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class MediaBuyFrequencyCapSupport(FrequencyCapConstraints): supported_control_modes: Annotated[ list[media_buy_frequency_cap_control_mode.MediaBuyFrequencyCapControlMode] | None, @@ -20,3 +26,21 @@ class MediaBuyFrequencyCapSupport(FrequencyCapConstraints): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> MediaBuyFrequencyCapSupport: + # Compiled from core/media-buy-frequency-cap-support.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'suppression_constraints')) + ): + raise PydanticCustomError( + 'allOf', + 'MediaBuyFrequencyCapSupport violates a constraint its schema states outside its properties (core/media-buy-frequency-cap-support.json, root conjunct 0) over suppression_constraints', + {'fields': 'suppression_constraints'}, + ) + return self diff --git a/src/adcp/types/domains/core/media_buy_support.py b/src/adcp/types/domains/core/media_buy_support.py index dcf3c1975..041bb7ab3 100644 --- a/src/adcp/types/domains/core/media_buy_support.py +++ b/src/adcp/types/domains/core/media_buy_support.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: core/media_buy_support.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from . import ext as ext_1 from . import media_buy_frequency_cap_support +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ProductMediaBuySupport(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -27,3 +33,37 @@ class ProductMediaBuySupport(AdCPBaseModel): media_buy_frequency_cap_support.MediaBuyFrequencyCapSupport | None ) = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProductMediaBuySupport: + # Compiled from core/media-buy-support.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'frequency_cap_constraints')) + or _c.supplied(self, 'frequency_cap') + ): + raise PydanticCustomError( + 'allOf', + 'ProductMediaBuySupport violates a constraint its schema states outside its properties (core/media-buy-support.json, root conjunct 0) over frequency_cap, frequency_cap_constraints', + {'fields': 'frequency_cap, frequency_cap_constraints'}, + ) + if not ( + not (_c.sent(self, 'ext') and _c.members(self, maximum=1)) + ): + raise PydanticCustomError( + 'allOf', + 'ProductMediaBuySupport violates a constraint its schema states outside its properties (core/media-buy-support.json, root conjunct 1) over ext', + {'fields': 'ext'}, + ) + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'ProductMediaBuySupport violates a constraint its schema states outside its properties (core/media-buy-support.json, root conjunct 2)', + ) + return self diff --git a/src/adcp/types/domains/core/media_buy_support_requirements.py b/src/adcp/types/domains/core/media_buy_support_requirements.py index 5647803db..87cfd8cf0 100644 --- a/src/adcp/types/domains/core/media_buy_support_requirements.py +++ b/src/adcp/types/domains/core/media_buy_support_requirements.py @@ -1,17 +1,23 @@ # generated by datamodel-codegen: # filename: core/media_buy_support_requirements.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from . import media_buy_frequency_cap_requirement +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ProductMediaBuySupportRequirements(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -25,3 +31,29 @@ class ProductMediaBuySupportRequirements(AdCPBaseModel): frequency_cap_constraints: ( media_buy_frequency_cap_requirement.MediaBuyFrequencyCapRequirement | None ) = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProductMediaBuySupportRequirements: + # Compiled from core/media-buy-support-requirements.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'frequency_cap_constraints')) + or _c.supplied(self, 'frequency_cap') + ): + raise PydanticCustomError( + 'allOf', + 'ProductMediaBuySupportRequirements violates a constraint its schema states outside its properties (core/media-buy-support-requirements.json, root conjunct 0) over frequency_cap, frequency_cap_constraints', + {'fields': 'frequency_cap, frequency_cap_constraints'}, + ) + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'ProductMediaBuySupportRequirements violates a constraint its schema states outside its properties (core/media-buy-support-requirements.json, root conjunct 1)', + ) + return self diff --git a/src/adcp/types/domains/core/notification_config.py b/src/adcp/types/domains/core/notification_config.py index 4d4905261..ea9693d86 100644 --- a/src/adcp/types/domains/core/notification_config.py +++ b/src/adcp/types/domains/core/notification_config.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/notification_config.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from pydantic import AnyUrl, ConfigDict, Field, StrictBool, model_validator from ..enums import auth_scheme from . import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class EventType(StrEnum): creative_status_changed = 'creative.status_changed' creative_assignment_changed = 'creative.assignment_changed' @@ -100,3 +106,21 @@ class NotificationConfig(AdCPBaseModel): ), ] = True ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> NotificationConfig: + # Compiled from core/notification-config.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'event_types', lambda event_types: _c.some(event_types, lambda item: item in ['product.created', 'product.updated', 'product.priced', 'product.removed'])) if _c.sent(self, 'product_payload_view') else True) + ): + raise PydanticCustomError( + 'allOf', + 'NotificationConfig violates a constraint its schema states outside its properties (core/notification-config.json, root conjunct 0) over event_types, product_payload_view', + {'fields': 'event_types, product_payload_view'}, + ) + return self diff --git a/src/adcp/types/domains/core/opportunity_context.py b/src/adcp/types/domains/core/opportunity_context.py index 495781001..e0648eaf9 100644 --- a/src/adcp/types/domains/core/opportunity_context.py +++ b/src/adcp/types/domains/core/opportunity_context.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/opportunity_context.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import date_range +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Phase(StrEnum): exploratory = 'exploratory' planning = 'planning' @@ -87,3 +93,29 @@ class OpportunityContext(AdCPBaseModel): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> OpportunityContext: + # Compiled from core/opportunity-context.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'close_reason') if (_c.descend(self, 'status', lambda status: status == 'closed') and _c.sent(self, 'status')) else not (_c.sent(self, 'close_reason') or _c.sent(self, 'close_detail'))) + ): + raise PydanticCustomError( + 'allOf', + 'OpportunityContext violates a constraint its schema states outside its properties (core/opportunity-context.json, root conjunct 0) over close_detail, close_reason, status', + {'fields': 'close_detail, close_reason, status'}, + ) + if not ( + (_c.sent(self, 'close_detail') if (_c.descend(self, 'close_reason', lambda close_reason: close_reason == 'other') and _c.sent(self, 'close_reason')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'OpportunityContext violates a constraint its schema states outside its properties (core/opportunity-context.json, root conjunct 1) over close_detail, close_reason', + {'fields': 'close_detail, close_reason'}, + ) + return self diff --git a/src/adcp/types/domains/core/package.py b/src/adcp/types/domains/core/package.py index 3c9438943..e0baf62ea 100644 --- a/src/adcp/types/domains/core/package.py +++ b/src/adcp/types/domains/core/package.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/package.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..enums import canceled_by as canceled_by_1 from ..enums import pacing as pacing_1 @@ -27,6 +27,12 @@ ) +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Cancellation(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -301,3 +307,45 @@ class Package(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> Package: + # Compiled from core/package.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'params')) or _c.supplied(self, 'format_kind') + ): + raise PydanticCustomError( + 'allOf', + 'Package violates a constraint its schema states outside its properties (core/package.json, root conjunct 0) over format_kind, params', + {'fields': 'format_kind, params'}, + ) + if not ( + not (_c.sent(self, 'bidding') and _c.sent(self, 'bid_price')) + ): + raise PydanticCustomError( + 'allOf', + 'Package violates a constraint its schema states outside its properties (core/package.json, root conjunct 1) over bid_price, bidding', + {'fields': 'bid_price, bidding'}, + ) + if not ( + (_c.descend(self, 'optimization_goals', lambda optimization_goals: not (_c.some(optimization_goals, lambda item: _c.descend(item, 'target', lambda target: _c.descend(target, 'kind', lambda kind: kind in ['cost_per', 'per_ad_spend']) and _c.supplied(target, 'kind')) and _c.supplied(item, 'target')))) if _c.sent(self, 'bidding') else True) + ): + raise PydanticCustomError( + 'allOf', + 'Package violates a constraint its schema states outside its properties (core/package.json, root conjunct 2) over bidding, kind, optimization_goals, target', + {'fields': 'bidding, kind, optimization_goals, target'}, + ) + if not ( + ((_c.descend(self, 'product_id', lambda product_id: _c.length(product_id, minimum=1)) and _c.sent(self, 'product_id')) if ((_c.descend(self, 'formats_to_provide', lambda formats_to_provide: _c.some(formats_to_provide, lambda item: _c.supplied(item, 'tracker_execution_contract'))) and _c.sent(self, 'formats_to_provide')) or (_c.descend(self, 'formats_pending', lambda formats_pending: _c.some(formats_pending, lambda item: _c.supplied(item, 'tracker_execution_contract'))) and _c.sent(self, 'formats_pending'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Package violates a constraint its schema states outside its properties (core/package.json, root conjunct 3) over formats_pending, formats_to_provide, product_id, tracker_execution_contract', + {'fields': 'formats_pending, formats_to_provide, product_id, tracker_execution_contract'}, + ) + return self diff --git a/src/adcp/types/domains/core/package_delivery_metric_value.py b/src/adcp/types/domains/core/package_delivery_metric_value.py index e54dda9c1..4f4fda5cb 100644 --- a/src/adcp/types/domains/core/package_delivery_metric_value.py +++ b/src/adcp/types/domains/core/package_delivery_metric_value.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/package_delivery_metric_value.json -# timestamp: 2026-10-04T00:56:30+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import attribution_methodology as attribution_methodology_1 from ..enums import completion_source as completion_source_1 @@ -17,6 +17,12 @@ from .delivery_metric_aggregate import Field0 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Qualifier(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -60,3 +66,21 @@ class PackageDeliveryMetricValue(Field0): description="Qualifier keys disambiguating this row from sibling rows under the same `metric_id`. Symmetric with `committed_metrics.qualifier` today; expected to diverge in future minors as transparency disclosures buyers don't commit to ship delivery-only. Closed (`additionalProperties: false`) — new qualifier keys ship explicitly." ), ] + @model_validator(mode='after') + def _require_schema_required_group(self) -> PackageDeliveryMetricValue: + # Compiled from core/package-delivery-metric-value.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.descend(self, 'metric_id', lambda metric_id: metric_id == 'spend') and _c.sent(self, 'metric_id')) + ): + raise PydanticCustomError( + 'allOf', + 'PackageDeliveryMetricValue violates a constraint its schema states outside its properties (core/package-delivery-metric-value.json, root conjunct 0) over metric_id', + {'fields': 'metric_id'}, + ) + return self diff --git a/src/adcp/types/domains/core/performance_feedback_assertion.py b/src/adcp/types/domains/core/performance_feedback_assertion.py index b7ee9f325..78ee18061 100644 --- a/src/adcp/types/domains/core/performance_feedback_assertion.py +++ b/src/adcp/types/domains/core/performance_feedback_assertion.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/performance_feedback_assertion.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..enums import feedback_source as feedback_source_1 from ..enums import metric_type as metric_type_1 @@ -15,6 +15,12 @@ from . import brand_ref, datetime_range, performance_feedback_metric +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ConfidenceInterval(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -161,3 +167,29 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> PerformanceFeedbackAssertion: + # Compiled from core/performance-feedback-assertion.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'methodology')) or _c.supplied(self, 'producer') + ): + raise PydanticCustomError( + 'allOf', + 'PerformanceFeedbackAssertion violates a constraint its schema states outside its properties (core/performance-feedback-assertion.json, root conjunct 0) over methodology, producer', + {'fields': 'methodology, producer'}, + ) + if not ( + not (_c.supplied(self, 'methodology_version')) or _c.supplied(self, 'producer') + ): + raise PydanticCustomError( + 'allOf', + 'PerformanceFeedbackAssertion violates a constraint its schema states outside its properties (core/performance-feedback-assertion.json, root conjunct 1) over methodology_version, producer', + {'fields': 'methodology_version, producer'}, + ) + return self diff --git a/src/adcp/types/domains/core/placement.py b/src/adcp/types/domains/core/placement.py index 74e87e5fa..051044c01 100644 --- a/src/adcp/types/domains/core/placement.py +++ b/src/adcp/types/domains/core/placement.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/placement.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from collections.abc import Sequence -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel, SchemaInt from pydantic import ConfigDict, Field, model_validator @@ -22,6 +22,9 @@ from . import format_id, product_format_declaration, seller_agent_ref +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -184,19 +187,53 @@ class Placement(AdCPBaseModel): ), ] = None dooh_placement_attributes: ProductDoohPlacementAttributes | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'name', 'publisher_domain'}) @model_validator(mode='after') def _require_schema_required_group(self) -> Placement: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('name',), ('publisher_domain',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'Placement requires at least one of these field groups: name | publisher_domain', - ) + # Compiled from core/placement.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # name + ( + _c.sent(self, 'name') + ) + # publisher_domain + or ( + _c.sent(self, 'publisher_domain') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'Placement requires at least one of these field groups: name | publisher_domain', + ) + if not ( + not (_c.supplied(self, 'visibility') or _c.supplied(self, 'source') or _c.supplied(self, 'origin') or _c.supplied(self, 'delivery_mappings')) + ): + raise PydanticCustomError( + 'allOf', + 'Placement violates a constraint its schema states outside its properties (core/placement.json, root conjunct 0) over delivery_mappings, origin, source, visibility', + {'fields': 'delivery_mappings, origin, source, visibility'}, + ) + if not ( + (_c.sent(self, 'publisher_domain') if (_c.descend(self, 'kind', lambda kind: kind == 'publisher_ref') and _c.sent(self, 'kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Placement violates a constraint its schema states outside its properties (core/placement.json, root conjunct 1) over kind, publisher_domain', + {'fields': 'kind, publisher_domain'}, + ) + if not ( + (_c.sent(self, 'name') if (_c.descend(self, 'kind', lambda kind: kind == 'seller_inline') and _c.sent(self, 'kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Placement violates a constraint its schema states outside its properties (core/placement.json, root conjunct 2) over kind, name', + {'fields': 'kind, name'}, + ) + return self diff --git a/src/adcp/types/domains/core/placement_definition.py b/src/adcp/types/domains/core/placement_definition.py index 31bcb111a..94cfd13aa 100644 --- a/src/adcp/types/domains/core/placement_definition.py +++ b/src/adcp/types/domains/core/placement_definition.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: core/placement_definition.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel, SchemaInt from pydantic import ConfigDict, Field, model_validator @@ -25,6 +25,9 @@ from . import product_format_declaration, property_id, property_tag +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -197,19 +200,37 @@ class PlacementDefinition(AdCPBaseModel): ] = None dooh_placement_attributes: PublisherDoohPlacementAttributes | None = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'property_ids', 'property_tags'}) @model_validator(mode='after') def _require_schema_required_group(self) -> PlacementDefinition: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('property_ids',), ('property_tags',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'PlacementDefinition requires at least one of these field groups: property_ids | property_tags', - ) + # Compiled from core/placement-definition.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # property_ids + ( + _c.sent(self, 'property_ids') + ) + # property_tags + or ( + _c.sent(self, 'property_tags') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'PlacementDefinition requires at least one of these field groups: property_ids | property_tags', + ) + if not ( + not (_c.supplied(self, 'visibility') or _c.supplied(self, 'source') or _c.supplied(self, 'origin') or _c.supplied(self, 'delivery_mappings') or _c.supplied(self, 'format_ids')) + ): + raise PydanticCustomError( + 'allOf', + 'PlacementDefinition violates a constraint its schema states outside its properties (core/placement-definition.json, root conjunct 0) over delivery_mappings, format_ids, origin, source, visibility', + {'fields': 'delivery_mappings, format_ids, origin, source, visibility'}, + ) + return self diff --git a/src/adcp/types/domains/core/planned_delivery.py b/src/adcp/types/domains/core/planned_delivery.py index d30ef3fb2..ccf0efb52 100644 --- a/src/adcp/types/domains/core/planned_delivery.py +++ b/src/adcp/types/domains/core/planned_delivery.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/planned_delivery.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..enums import channels as channels_1 from ..enums import pacing as pacing_1 @@ -17,6 +17,12 @@ from . import frequency_cap as frequency_cap_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Geo(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -129,3 +135,29 @@ class PlannedDelivery(AdCPBaseModel): Field(description='Registry policy IDs the seller will enforce for this delivery.'), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> PlannedDelivery: + # Compiled from core/planned-delivery.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'proposal_terms_digest') if _c.sent(self, 'proposal_id') else True) + ): + raise PydanticCustomError( + 'allOf', + 'PlannedDelivery violates a constraint its schema states outside its properties (core/planned-delivery.json, root conjunct 0) over proposal_id, proposal_terms_digest', + {'fields': 'proposal_id, proposal_terms_digest'}, + ) + if not ( + (_c.sent(self, 'proposal_id') if _c.sent(self, 'proposal_terms_digest') else True) + ): + raise PydanticCustomError( + 'allOf', + 'PlannedDelivery violates a constraint its schema states outside its properties (core/planned-delivery.json, root conjunct 1) over proposal_id, proposal_terms_digest', + {'fields': 'proposal_id, proposal_terms_digest'}, + ) + return self diff --git a/src/adcp/types/domains/core/positive_postal_area_support.py b/src/adcp/types/domains/core/positive_postal_area_support.py index 89e553e00..6a1b1098c 100644 --- a/src/adcp/types/domains/core/positive_postal_area_support.py +++ b/src/adcp/types/domains/core/positive_postal_area_support.py @@ -1,16 +1,22 @@ # generated by datamodel-codegen: # filename: core/positive_postal_area_support.json -# timestamp: 2026-10-03T14:21:15+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal -from pydantic import Field +from pydantic import Field, model_validator from .postal_area_support import PostalAreaSupport +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class PositivePostalAreaSupport(PostalAreaSupport): us_zip: Annotated[Literal[True] | None, Field(deprecated=True)] = None us_zip_plus_four: Annotated[Literal[True] | None, Field(deprecated=True)] = None @@ -23,3 +29,20 @@ class PositivePostalAreaSupport(PostalAreaSupport): au_postcode: Annotated[Literal[True] | None, Field(deprecated=True)] = None ch_plz: Annotated[Literal[True] | None, Field(deprecated=True)] = None at_plz: Annotated[Literal[True] | None, Field(deprecated=True)] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> PositivePostalAreaSupport: + # Compiled from core/positive-postal-area-support.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'PositivePostalAreaSupport violates a constraint its schema states outside its properties (core/positive-postal-area-support.json, root conjunct 0)', + ) + return self diff --git a/src/adcp/types/domains/core/postal_area_support.py b/src/adcp/types/domains/core/postal_area_support.py index 2dc9e2910..5fdfdf60d 100644 --- a/src/adcp/types/domains/core/postal_area_support.py +++ b/src/adcp/types/domains/core/postal_area_support.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/postal_area_support.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated, Dict, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class ME(StrEnum): @@ -60,3 +66,20 @@ class PostalAreaSupport(AdCPBaseModel): au_postcode: Annotated[StrictBool | None, Field(deprecated=True)] = None ch_plz: Annotated[StrictBool | None, Field(deprecated=True)] = None at_plz: Annotated[StrictBool | None, Field(deprecated=True)] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> PostalAreaSupport: + # Compiled from core/postal-area-support.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.keys(self, lambda name: _c.matches(name, '^[A-Z]{2}$') or (name in ['us_zip', 'us_zip_plus_four', 'gb_outward', 'gb_full', 'ca_fsa', 'ca_full', 'de_plz', 'fr_code_postal', 'au_postcode', 'ch_plz', 'at_plz'] and _c.json_type(name, 'string'))) + ): + raise PydanticCustomError( + 'allOf', + 'PostalAreaSupport violates a constraint its schema states outside its properties (core/postal-area-support.json, root conjunct 0)', + ) + return self diff --git a/src/adcp/types/domains/core/principal_state.py b/src/adcp/types/domains/core/principal_state.py index e903c6ce9..dd8cc2ab3 100644 --- a/src/adcp/types/domains/core/principal_state.py +++ b/src/adcp/types/domains/core/principal_state.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: core/principal_state.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import ( agent_notification_config_state, @@ -17,6 +16,13 @@ ) +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class DestinationRef(ScalarStr): __slots__ = () _constraints = {'max_length': 255, 'min_length': 1} @@ -71,3 +77,20 @@ class PrincipalState(AdCPBaseModel): max_length=64, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> PrincipalState: + # Compiled from core/principal-state.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'PrincipalState violates a constraint its schema states outside its properties (core/principal-state.json, root conjunct 0)', + ) + return self diff --git a/src/adcp/types/domains/core/product.py b/src/adcp/types/domains/core/product.py index fadcdbbf5..07a42041b 100644 --- a/src/adcp/types/domains/core/product.py +++ b/src/adcp/types/domains/core/product.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: core/product.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum -from typing import Any, Annotated, Literal +from typing import Annotated, Any, ClassVar, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarFloat, ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, model_validator from ..enums import action_source, audio_distribution_type, catalog_type @@ -60,6 +59,10 @@ from .assets import image_asset +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarFloat, ScalarStr + + from pydantic_core import PydanticCustomError @@ -881,19 +884,117 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: ), ] = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'format_ids', 'format_options'}) @model_validator(mode='after') def _require_schema_required_group(self) -> Product: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('format_ids',), ('format_options',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'Product requires at least one of these field groups: format_ids | format_options', - ) + # Compiled from core/product.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # Named-format product + ( + _c.sent(self, 'format_ids') + ) + # Canonical-format product + or ( + _c.sent(self, 'format_options') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'Product requires at least one of these field groups: Named-format product | Canonical-format product', + ) + if not ( + ((_c.descend(self, 'signal_targeting_allowed', lambda signal_targeting_allowed: signal_targeting_allowed == True) and _c.sent(self, 'signal_targeting_allowed')) if (_c.sent(self, 'signal_targeting_options') or _c.sent(self, 'signal_targeting_rules')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 0) over signal_targeting_allowed, signal_targeting_options, signal_targeting_rules', + {'fields': 'signal_targeting_allowed, signal_targeting_options, signal_targeting_rules'}, + ) + if not ( + (_c.sent(self, 'conversion_tracking') if (_c.descend(self, 'execution_requirements', lambda execution_requirements: _c.some(execution_requirements, lambda item: _c.descend(item, 'kind', lambda kind: kind == 'event_source') and _c.supplied(item, 'kind'))) and _c.sent(self, 'execution_requirements')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 1) over conversion_tracking, execution_requirements, kind', + {'fields': 'conversion_tracking, execution_requirements, kind'}, + ) + if not ( + (_c.sent(self, 'catalog_types') if (_c.descend(self, 'execution_requirements', lambda execution_requirements: _c.some(execution_requirements, lambda item: _c.descend(item, 'kind', lambda kind: kind == 'catalog') and _c.supplied(item, 'kind'))) and _c.sent(self, 'execution_requirements')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 2) over catalog_types, execution_requirements, kind', + {'fields': 'catalog_types, execution_requirements, kind'}, + ) + if not ( + ((_c.descend(self, 'media_buy_support', lambda media_buy_support: not (_c.sent(media_buy_support, 'frequency_cap'))) and _c.descend(self, 'metric_optimization', lambda metric_optimization: _c.descend(metric_optimization, 'supported_reach_units', lambda supported_reach_units: not (_c.some(supported_reach_units, lambda item: item in ['cookies', 'devices', 'accounts'])))) and _c.descend(self, 'overlay_support', lambda overlay_support: not (_c.sent(overlay_support, 'frequency_cap') or _c.sent(overlay_support, 'frequency_cap_support'))) and _c.descend(self, 'reporting_capabilities', lambda reporting_capabilities: _c.descend(reporting_capabilities, 'supported_reach_units', lambda supported_reach_units: not (_c.some(supported_reach_units, lambda item: item in ['cookies', 'devices', 'accounts']))))) if (_c.descend(self, 'identity', lambda identity: _c.descend(identity, 'persistent_identifier', lambda persistent_identifier: persistent_identifier == False) and _c.sent(identity, 'persistent_identifier')) and _c.sent(self, 'identity')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 3) over frequency_cap, frequency_cap_support, identity, media_buy_support, metric_optimization, overlay_support, persistent_identifier, reporting_capabilities, supported_reach_units', + {'fields': 'frequency_cap, frequency_cap_support, identity, media_buy_support, metric_optimization, overlay_support, persistent_identifier, reporting_capabilities, supported_reach_units'}, + ) + if not ( + ((_c.descend(self, 'is_custom', lambda is_custom: is_custom == True) and (_c.sent(self, 'is_custom') and _c.sent(self, 'expires_at'))) if _c.sent(self, 'targeting_resolution') else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 4) over expires_at, is_custom, targeting_resolution', + {'fields': 'expires_at, is_custom, targeting_resolution'}, + ) + if not ( + ((_c.descend(self, 'property_targeting_allowed', lambda property_targeting_allowed: property_targeting_allowed == True) and _c.sent(self, 'property_targeting_allowed')) if (_c.descend(self, 'overlay_support', lambda overlay_support: _c.sent(overlay_support, 'property_list')) and _c.sent(self, 'overlay_support')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 5) over overlay_support, property_list, property_targeting_allowed', + {'fields': 'overlay_support, property_list, property_targeting_allowed'}, + ) + if not ( + ((_c.descend(self, 'placements', lambda placements: not (_c.some(placements, lambda item: _c.descend(item, 'mode', lambda mode: mode == 'included') and _c.supplied(item, 'mode')))) and _c.sent(self, 'placements')) if (_c.descend(self, 'overlay_support', lambda overlay_support: _c.sent(overlay_support, 'placement_selection')) and _c.sent(self, 'overlay_support')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 6) over mode, overlay_support, placement_selection, placements', + {'fields': 'mode, overlay_support, placement_selection, placements'}, + ) + if not ( + ((_c.descend(self, 'collection_targeting_allowed', lambda collection_targeting_allowed: collection_targeting_allowed == True) and _c.sent(self, 'collection_targeting_allowed')) if (_c.descend(self, 'overlay_support', lambda overlay_support: _c.sent(overlay_support, 'collection_list')) and _c.sent(self, 'overlay_support')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 7) over collection_list, collection_targeting_allowed, overlay_support', + {'fields': 'collection_list, collection_targeting_allowed, overlay_support'}, + ) + if not ( + ((_c.descend(self, 'placements', lambda placements: _c.some(placements, lambda item: _c.descend(item, 'mode', lambda mode: mode == 'targetable') and _c.supplied(item, 'mode') and _c.json_type(item, 'object'))) and _c.sent(self, 'placements')) if (_c.descend(self, 'overlay_support', lambda overlay_support: _c.sent(overlay_support, 'placement_selection')) and _c.sent(self, 'overlay_support')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 8) over mode, overlay_support, placement_selection, placements', + {'fields': 'mode, overlay_support, placement_selection, placements'}, + ) + if not ( + (_c.sent(self, 'demographic_targeting') if (_c.descend(self, 'overlay_support', lambda overlay_support: _c.sent(overlay_support, 'demographics')) and _c.sent(self, 'overlay_support')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 9) over demographic_targeting, demographics, overlay_support', + {'fields': 'demographic_targeting, demographics, overlay_support'}, + ) + if not ( + ((_c.descend(self, 'signal_targeting_allowed', lambda signal_targeting_allowed: signal_targeting_allowed == True) and _c.sent(self, 'signal_targeting_allowed')) if (_c.descend(self, 'overlay_support', lambda overlay_support: _c.sent(overlay_support, 'signal_targeting_groups')) and _c.sent(self, 'overlay_support')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Product violates a constraint its schema states outside its properties (core/product.json, root conjunct 10) over overlay_support, signal_targeting_allowed, signal_targeting_groups', + {'fields': 'overlay_support, signal_targeting_allowed, signal_targeting_groups'}, + ) + return self diff --git a/src/adcp/types/domains/core/product_audience_evidence_requirements.py b/src/adcp/types/domains/core/product_audience_evidence_requirements.py index d3421157a..9a3b01246 100644 --- a/src/adcp/types/domains/core/product_audience_evidence_requirements.py +++ b/src/adcp/types/domains/core/product_audience_evidence_requirements.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/product_audience_evidence_requirements.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type from . import brand_key @@ -16,6 +16,12 @@ from .duration import Duration +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class RequirementMode(StrEnum): required = 'required' preferred = 'preferred' @@ -113,3 +119,21 @@ class ProductAudienceEvidenceRequirements(AdCPBaseModel): ] = None accepted_attestation_claim_types: Annotated[list[AnyUrl] | None, Field(min_length=1)] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProductAudienceEvidenceRequirements: + # Compiled from core/product-audience-evidence-requirements.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'accepted_attestation_issuers') if (_c.descend(self, 'independent_attestation_required', lambda independent_attestation_required: independent_attestation_required == True) and _c.sent(self, 'independent_attestation_required')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ProductAudienceEvidenceRequirements violates a constraint its schema states outside its properties (core/product-audience-evidence-requirements.json, root conjunct 0) over accepted_attestation_issuers, independent_attestation_required', + {'fields': 'accepted_attestation_issuers, independent_attestation_required'}, + ) + return self diff --git a/src/adcp/types/domains/core/product_card_reference_asset.py b/src/adcp/types/domains/core/product_card_reference_asset.py index b3586e47e..e99da17b4 100644 --- a/src/adcp/types/domains/core/product_card_reference_asset.py +++ b/src/adcp/types/domains/core/product_card_reference_asset.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/product_card_reference_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from .assets import image_asset, markdown_asset, url_asset, video_asset +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Role(StrEnum): coverage_map = 'coverage_map' sample_render = 'sample_render' @@ -50,3 +56,21 @@ class ProductCardReferenceAsset(AdCPBaseModel): description: Annotated[ str | None, Field(description='Optional human-readable context about this asset.') ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProductCardReferenceAsset: + # Compiled from core/product-card-reference-asset.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'role_label') if (_c.descend(self, 'role', lambda role: role == 'other') and _c.sent(self, 'role')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ProductCardReferenceAsset violates a constraint its schema states outside its properties (core/product-card-reference-asset.json, root conjunct 0) over role, role_label', + {'fields': 'role, role_label'}, + ) + return self diff --git a/src/adcp/types/domains/core/product_offer_filters.py b/src/adcp/types/domains/core/product_offer_filters.py index da9a509ab..3507e4fe9 100644 --- a/src/adcp/types/domains/core/product_offer_filters.py +++ b/src/adcp/types/domains/core/product_offer_filters.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/product_offer_filters.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -9,8 +9,7 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..enums import audio_distribution_type, available_metric from ..enums import channels as channels_1 @@ -40,6 +39,13 @@ ) +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class PricingCurrency(ScalarStr): __slots__ = () _constraints = {'pattern': '^[A-Z]{3}$'} @@ -320,3 +326,21 @@ class ProductOfferFilters(AdCPBaseModel): product_audience_evidence_requirements.ProductAudienceEvidenceRequirements | None ) = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProductOfferFilters: + # Compiled from core/product-offer-filters.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not ((_c.sent(self, 'availability_horizon') and _c.sent(self, 'start_date')) or (_c.sent(self, 'availability_horizon') and _c.sent(self, 'end_date'))) + ): + raise PydanticCustomError( + 'allOf', + 'ProductOfferFilters violates a constraint its schema states outside its properties (core/product-offer-filters.json, root conjunct 0) over availability_horizon, end_date, start_date', + {'fields': 'availability_horizon, end_date, start_date'}, + ) + return self diff --git a/src/adcp/types/domains/core/product_signal_targeting_option.py b/src/adcp/types/domains/core/product_signal_targeting_option.py index fe0551364..7a85b6b9a 100644 --- a/src/adcp/types/domains/core/product_signal_targeting_option.py +++ b/src/adcp/types/domains/core/product_signal_targeting_option.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: core/product_signal_targeting_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from . import signal_ref, vendor_pricing_option from .signal_listing import SignalListing +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ActivationStatus(StrEnum): ready = 'ready' requires_activation = 'requires_activation' @@ -71,3 +77,21 @@ class ProductSignalTargetingOption(SignalListing): description="Canonical signal reference. Use scope 'product' for a product-local signal defined by this listing; use scope 'data_provider' with data_provider_domain for a signal defined in a data provider's published adagents.json signals[]; use scope 'signal_source' with signal_source_url for a source-native signal." ), ] + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProductSignalTargetingOption: + # Compiled from core/product-signal-targeting-option.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'signal_agent_segment_id') if (_c.descend(self, 'activation_status', lambda activation_status: activation_status == 'requires_activation') and _c.sent(self, 'activation_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ProductSignalTargetingOption violates a constraint its schema states outside its properties (core/product-signal-targeting-option.json, root conjunct 0) over activation_status, signal_agent_segment_id', + {'fields': 'activation_status, signal_agent_segment_id'}, + ) + return self diff --git a/src/adcp/types/domains/core/proposal.py b/src/adcp/types/domains/core/proposal.py index 04b10b2aa..1ad84810f 100644 --- a/src/adcp/types/domains/core/proposal.py +++ b/src/adcp/types/domains/core/proposal.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/proposal.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..enums import pacing as pacing_1 from ..enums import proposal_status as proposal_status_1 @@ -18,6 +18,12 @@ from . import media_buy_frequency_cap, product_allocation +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class TotalBudgetGuidance(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -114,3 +120,29 @@ class Proposal(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> Proposal: + # Compiled from core/proposal.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'allocations', lambda allocations: _c.every(allocations, lambda item: not (_c.sent(item, 'allocation_percentage')))) if (_c.descend(self, 'budget_allocation', lambda budget_allocation: _c.descend(budget_allocation, 'mode', lambda mode: mode == 'seller_optimized') and _c.sent(budget_allocation, 'mode')) and _c.sent(self, 'budget_allocation')) else _c.descend(self, 'allocations', lambda allocations: _c.every(allocations, lambda item: not (_c.sent(item, 'min_spend_target_percentage') or _c.sent(item, 'max_spend_percentage')) and _c.sent(item, 'allocation_percentage')))) + ): + raise PydanticCustomError( + 'allOf', + 'Proposal violates a constraint its schema states outside its properties (core/proposal.json, root conjunct 0) over allocation_percentage, allocations, budget_allocation, max_spend_percentage, min_spend_target_percentage, mode', + {'fields': 'allocation_percentage, allocations, budget_allocation, max_spend_percentage, min_spend_target_percentage, mode'}, + ) + if not ( + (_c.descend(self, 'allocations', lambda allocations: _c.every(allocations, lambda item: _c.sent(item, 'pricing_option_id'))) if (_c.descend(self, 'proposal_status', lambda proposal_status: proposal_status == 'committed') and _c.sent(self, 'proposal_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Proposal violates a constraint its schema states outside its properties (core/proposal.json, root conjunct 1) over allocations, pricing_option_id, proposal_status', + {'fields': 'allocations, pricing_option_id, proposal_status'}, + ) + return self diff --git a/src/adcp/types/domains/core/protocol_envelope.py b/src/adcp/types/domains/core/protocol_envelope.py index 7f75dfd17..9405710f8 100644 --- a/src/adcp/types/domains/core/protocol_envelope.py +++ b/src/adcp/types/domains/core/protocol_envelope.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/protocol_envelope.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator from ..enums import task_status from . import context as context_1 @@ -15,6 +15,12 @@ from . import push_notification_config as push_notification_config_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ProtocolEnvelope(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -88,3 +94,21 @@ class ProtocolEnvelope(AdCPBaseModel): description='Conceptual grouping for the task-specific response data defined by individual task response schemas (e.g., get-products-response.json, create-media-buy-response.json). `payload` is a documentary construct — it is NOT a required wire field, and its on-the-wire shape depends on transport (see Transport serialization below). Task response schemas declare body fields without wrapping them in a `payload` object; the wire representation places those body fields per transport convention. On MCP the body fields appear as siblings of envelope fields at the root of the tool response; on A2A they appear inside `task.artifacts[0].parts[].DataPart`; on REST they appear at the root of the JSON body.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProtocolEnvelope: + # Compiled from core/protocol-envelope.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'task_status') or _c.supplied(self, 'response_status')) + ): + raise PydanticCustomError( + 'allOf', + 'ProtocolEnvelope violates a constraint its schema states outside its properties (core/protocol-envelope.json, root conjunct 0) over response_status, task_status', + {'fields': 'response_status, task_status'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_adjustment_receipt.py b/src/adcp/types/domains/core/reporting_adjustment_receipt.py index 4efbdd996..8650f4ebb 100644 --- a/src/adcp/types/domains/core/reporting_adjustment_receipt.py +++ b/src/adcp/types/domains/core/reporting_adjustment_receipt.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_adjustment_receipt.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,8 +8,14 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel +from pydantic import AwareDatetime, ConfigDict, Field, model_validator + + +from adcp.types import _conditions as _c from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field + + +from pydantic_core import PydanticCustomError class Status(StrEnum): @@ -57,3 +63,21 @@ class ReportingAdjustmentReceipt(AdCPBaseModel): ] = None observed_at: AwareDatetime received_at: AwareDatetime | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingAdjustmentReceipt: + # Compiled from core/reporting-adjustment-receipt.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'rejection_codes') if (_c.descend(self, 'status', lambda status: status == 'rejected') and _c.sent(self, 'status')) else not (_c.sent(self, 'rejection_codes'))) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingAdjustmentReceipt violates a constraint its schema states outside its properties (core/reporting-adjustment-receipt.json, root conjunct 0) over rejection_codes, status', + {'fields': 'rejection_codes, status'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_consumer_status.py b/src/adcp/types/domains/core/reporting_consumer_status.py index b8f56908b..88cc04d26 100644 --- a/src/adcp/types/domains/core/reporting_consumer_status.py +++ b/src/adcp/types/domains/core/reporting_consumer_status.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_consumer_status.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class Period(AdCPBaseModel): @@ -162,3 +168,61 @@ class ReportingConsumerStatus(AdCPBaseModel): AwareDatetime | None, Field(description='When the seller durably recorded this immutable statement.'), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingConsumerStatus: + # Compiled from core/reporting-consumer-status.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((not (_c.sent(self, 'failure_code') or _c.sent(self, 'mismatch_code')) and (_c.sent(self, 'reporting_obligation_id') and _c.sent(self, 'reporting_revision_id') and _c.sent(self, 'observed_revision_content_sha256'))) if (_c.descend(self, 'consumer_status', lambda consumer_status: consumer_status == 'received') and _c.sent(self, 'consumer_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingConsumerStatus violates a constraint its schema states outside its properties (core/reporting-consumer-status.json, root conjunct 0) over consumer_status, failure_code, mismatch_code, observed_revision_content_sha256, reporting_obligation_id, reporting_revision_id', + {'fields': 'consumer_status, failure_code, mismatch_code, observed_revision_content_sha256, reporting_obligation_id, reporting_revision_id'}, + ) + if not ( + (not (_c.sent(self, 'reporting_obligation_id') or _c.sent(self, 'reporting_revision_id') or _c.sent(self, 'observed_revision_content_sha256') or _c.sent(self, 'failure_code') or _c.sent(self, 'mismatch_code')) if (_c.descend(self, 'consumer_status', lambda consumer_status: consumer_status == 'obligation_missing') and _c.sent(self, 'consumer_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingConsumerStatus violates a constraint its schema states outside its properties (core/reporting-consumer-status.json, root conjunct 1) over consumer_status, failure_code, mismatch_code, observed_revision_content_sha256, reporting_obligation_id, reporting_revision_id', + {'fields': 'consumer_status, failure_code, mismatch_code, observed_revision_content_sha256, reporting_obligation_id, reporting_revision_id'}, + ) + if not ( + ((not (_c.sent(self, 'reporting_revision_id') or _c.sent(self, 'observed_revision_content_sha256') or _c.sent(self, 'failure_code') or _c.sent(self, 'mismatch_code')) and _c.sent(self, 'reporting_obligation_id')) if (_c.descend(self, 'consumer_status', lambda consumer_status: consumer_status == 'revision_missing') and _c.sent(self, 'consumer_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingConsumerStatus violates a constraint its schema states outside its properties (core/reporting-consumer-status.json, root conjunct 2) over consumer_status, failure_code, mismatch_code, observed_revision_content_sha256, reporting_obligation_id, reporting_revision_id', + {'fields': 'consumer_status, failure_code, mismatch_code, observed_revision_content_sha256, reporting_obligation_id, reporting_revision_id'}, + ) + if not ( + ((not (_c.sent(self, 'observed_revision_content_sha256') or _c.sent(self, 'mismatch_code')) and (_c.sent(self, 'reporting_obligation_id') and _c.sent(self, 'reporting_revision_id') and _c.sent(self, 'failure_code'))) if (_c.descend(self, 'consumer_status', lambda consumer_status: consumer_status == 'unreadable') and _c.sent(self, 'consumer_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingConsumerStatus violates a constraint its schema states outside its properties (core/reporting-consumer-status.json, root conjunct 3) over consumer_status, failure_code, mismatch_code, observed_revision_content_sha256, reporting_obligation_id, reporting_revision_id', + {'fields': 'consumer_status, failure_code, mismatch_code, observed_revision_content_sha256, reporting_obligation_id, reporting_revision_id'}, + ) + if not ( + ((not (_c.sent(self, 'failure_code')) and (_c.sent(self, 'reporting_obligation_id') and _c.sent(self, 'reporting_revision_id') and _c.sent(self, 'observed_revision_content_sha256') and _c.sent(self, 'mismatch_code'))) if (_c.descend(self, 'consumer_status', lambda consumer_status: consumer_status == 'content_mismatch') and _c.sent(self, 'consumer_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingConsumerStatus violates a constraint its schema states outside its properties (core/reporting-consumer-status.json, root conjunct 4) over consumer_status, failure_code, mismatch_code, observed_revision_content_sha256, reporting_obligation_id, reporting_revision_id', + {'fields': 'consumer_status, failure_code, mismatch_code, observed_revision_content_sha256, reporting_obligation_id, reporting_revision_id'}, + ) + if not ( + (_c.sent(self, 'seller_ledger_as_of') if _c.sent(self, 'seller_ledger_snapshot_id') else not (_c.sent(self, 'seller_ledger_as_of'))) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingConsumerStatus violates a constraint its schema states outside its properties (core/reporting-consumer-status.json, root conjunct 5) over seller_ledger_as_of, seller_ledger_snapshot_id', + {'fields': 'seller_ledger_as_of, seller_ledger_snapshot_id'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_delivery_capabilities.py b/src/adcp/types/domains/core/reporting_delivery_capabilities.py index 6770cc64f..ce3f5003a 100644 --- a/src/adcp/types/domains/core/reporting_delivery_capabilities.py +++ b/src/adcp/types/domains/core/reporting_delivery_capabilities.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_capabilities.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,11 +10,17 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, ConfigDict, EmailStr, Field, StrictBool +from pydantic import AnyUrl, ConfigDict, EmailStr, Field, StrictBool, model_validator from . import reporting_delivery_offering, reporting_reliability_statistics +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class OperationsContact(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -151,6 +157,80 @@ class ReportingDeliveryCapabilities(AdCPBaseModel): ge=0, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingDeliveryCapabilities: + # Compiled from core/reporting-delivery-capabilities.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'reliable_reporting_version') and _c.sent(self, 'revision_content_task')) if _c.sent(self, 'consumer_status_task') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryCapabilities violates a constraint its schema states outside its properties (core/reporting-delivery-capabilities.json, root conjunct 0) over consumer_status_task, reliable_reporting_version, revision_content_task', + {'fields': 'consumer_status_task, reliable_reporting_version, revision_content_task'}, + ) + if not ( + ((_c.sent(self, 'consumer_status_task') and _c.sent(self, 'operations_contact')) if _c.sent(self, 'consumer_mismatch_escalation_seconds') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryCapabilities violates a constraint its schema states outside its properties (core/reporting-delivery-capabilities.json, root conjunct 1) over consumer_mismatch_escalation_seconds, consumer_status_task, operations_contact', + {'fields': 'consumer_mismatch_escalation_seconds, consumer_status_task, operations_contact'}, + ) + if not ( + (_c.sent(self, 'revision_content_task') if _c.sent(self, 'reliable_reporting_version') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryCapabilities violates a constraint its schema states outside its properties (core/reporting-delivery-capabilities.json, root conjunct 2) over reliable_reporting_version, revision_content_task', + {'fields': 'reliable_reporting_version, revision_content_task'}, + ) + if not ( + ((_c.descend(self, 'offerings', lambda offerings: _c.some(offerings, lambda item: _c.supplied(item, 'method'))) and (_c.sent(self, 'resource_retention_days') and _c.sent(self, 'authorization_revocation_seconds'))) if (_c.descend(self, 'managed_delivery', lambda managed_delivery: managed_delivery == True) and _c.sent(self, 'managed_delivery')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryCapabilities violates a constraint its schema states outside its properties (core/reporting-delivery-capabilities.json, root conjunct 3) over authorization_revocation_seconds, managed_delivery, method, offerings, resource_retention_days', + {'fields': 'authorization_revocation_seconds, managed_delivery, method, offerings, resource_retention_days'}, + ) + if not ( + ((_c.descend(self, 'offerings', lambda offerings: _c.some(offerings, lambda item: _c.descend(item, 'reconciliation_mode', lambda reconciliation_mode: reconciliation_mode == 'consumer_receipt') and _c.supplied(item, 'reconciliation_mode'))) and _c.sent(self, 'receipt_task')) if (_c.descend(self, 'reconciled_billing', lambda reconciled_billing: reconciled_billing == True) and _c.sent(self, 'reconciled_billing')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryCapabilities violates a constraint its schema states outside its properties (core/reporting-delivery-capabilities.json, root conjunct 4) over offerings, receipt_task, reconciled_billing, reconciliation_mode', + {'fields': 'offerings, receipt_task, reconciled_billing, reconciliation_mode'}, + ) + if not ( + ((_c.descend(self, 'managed_delivery', lambda managed_delivery: managed_delivery == True) and _c.sent(self, 'managed_delivery')) if (_c.descend(self, 'offerings', lambda offerings: _c.some(offerings, lambda item: _c.supplied(item, 'method'))) and _c.sent(self, 'offerings')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryCapabilities violates a constraint its schema states outside its properties (core/reporting-delivery-capabilities.json, root conjunct 5) over managed_delivery, method, offerings', + {'fields': 'managed_delivery, method, offerings'}, + ) + if not ( + ((_c.descend(self, 'reconciled_billing', lambda reconciled_billing: reconciled_billing == True) and _c.sent(self, 'reconciled_billing')) if (_c.descend(self, 'offerings', lambda offerings: _c.some(offerings, lambda item: _c.descend(item, 'reconciliation_mode', lambda reconciliation_mode: reconciliation_mode == 'consumer_receipt') and _c.supplied(item, 'reconciliation_mode'))) and _c.sent(self, 'offerings')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryCapabilities violates a constraint its schema states outside its properties (core/reporting-delivery-capabilities.json, root conjunct 6) over offerings, reconciled_billing, reconciliation_mode', + {'fields': 'offerings, reconciled_billing, reconciliation_mode'}, + ) + if not ( + ((_c.descend(self, 'managed_delivery', lambda managed_delivery: managed_delivery == True) and _c.sent(self, 'managed_delivery')) if _c.sent(self, 'readiness_notification') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryCapabilities violates a constraint its schema states outside its properties (core/reporting-delivery-capabilities.json, root conjunct 7) over managed_delivery, readiness_notification', + {'fields': 'managed_delivery, readiness_notification'}, + ) + return self @model_validator(mode='after') def _validate_reporting_tiers(self) -> ReportingDeliveryCapabilities: diff --git a/src/adcp/types/domains/core/reporting_delivery_config.py b/src/adcp/types/domains/core/reporting_delivery_config.py index 44ab8c951..890aaf223 100644 --- a/src/adcp/types/domains/core/reporting_delivery_config.py +++ b/src/adcp/types/domains/core/reporting_delivery_config.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_config.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -12,7 +12,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator from ..enums import reporting_finality from . import ( @@ -24,6 +24,12 @@ ) +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Scope(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -162,3 +168,37 @@ class ReportingDeliveryConfiguration(AdCPBaseModel): description='Optional requested cutoff for deactivation. No new publication may begin after the applied cutoff; historical access is limited to the contracted recovery window.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingDeliveryConfiguration: + # Compiled from core/reporting-delivery-config.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'reconciliation_mode', lambda reconciliation_mode: reconciliation_mode == 'consumer_receipt') if ((_c.descend(self, 'authoritative_party', lambda authoritative_party: authoritative_party == 'seller') and _c.descend(self, 'feed_purpose', lambda feed_purpose: feed_purpose == 'billing')) and _c.sent(self, 'feed_purpose')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryConfiguration violates a constraint its schema states outside its properties (core/reporting-delivery-config.json, root conjunct 0) over authoritative_party, feed_purpose, reconciliation_mode', + {'fields': 'authoritative_party, feed_purpose, reconciliation_mode'}, + ) + if not ( + (_c.descend(self, 'required_finality', lambda required_finality: required_finality == 'official') if (_c.descend(self, 'feed_purpose', lambda feed_purpose: feed_purpose == 'billing') and _c.sent(self, 'feed_purpose')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryConfiguration violates a constraint its schema states outside its properties (core/reporting-delivery-config.json, root conjunct 1) over feed_purpose, required_finality', + {'fields': 'feed_purpose, required_finality'}, + ) + if not ( + (_c.sent(self, 'method') if (_c.descend(self, 'reconciliation_mode', lambda reconciliation_mode: reconciliation_mode == 'consumer_receipt') and _c.sent(self, 'reconciliation_mode')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryConfiguration violates a constraint its schema states outside its properties (core/reporting-delivery-config.json, root conjunct 2) over method, reconciliation_mode', + {'fields': 'method, reconciliation_mode'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_delivery_config_state.py b/src/adcp/types/domains/core/reporting_delivery_config_state.py index dfe7b6cd4..725cfd1f7 100644 --- a/src/adcp/types/domains/core/reporting_delivery_config_state.py +++ b/src/adcp/types/domains/core/reporting_delivery_config_state.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_config_state.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator from . import reporting_coverage, reporting_delivery_config, reporting_status_issue +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Action(StrEnum): grant_access = 'grant_access' activate_recipient = 'activate_recipient' @@ -89,3 +95,37 @@ class ReportingDeliveryConfigurationState(AdCPBaseModel): issues: Annotated[ list[reporting_status_issue.ReportingStatusIssue] | None, Field(min_length=1) ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingDeliveryConfigurationState: + # Compiled from core/reporting-delivery-config-state.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((not (_c.sent(self, 'setup') or _c.sent(self, 'issues') or _c.sent(self, 'deactivated_at')) and _c.descend(self, 'configuration', lambda configuration: _c.descend(configuration, 'active', lambda active: active == True)) and (_c.sent(self, 'validated_at') and _c.sent(self, 'activated_at') and _c.sent(self, 'current_coverage'))) if _c.descend(self, 'state', lambda state: state == 'ready') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryConfigurationState violates a constraint its schema states outside its properties (core/reporting-delivery-config-state.json, root conjunct 0) over activated_at, active, configuration, current_coverage, deactivated_at, issues, setup, state, validated_at', + {'fields': 'activated_at, active, configuration, current_coverage, deactivated_at, issues, setup, state, validated_at'}, + ) + if not ( + ((_c.sent(self, 'setup') or _c.sent(self, 'issues')) if _c.descend(self, 'state', lambda state: state in ['pending_setup', 'action_required']) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryConfigurationState violates a constraint its schema states outside its properties (core/reporting-delivery-config-state.json, root conjunct 1) over issues, setup, state', + {'fields': 'issues, setup, state'}, + ) + if not ( + ((_c.sent(self, 'deactivated_at') and _c.sent(self, 'publication_stopped_at')) if _c.descend(self, 'state', lambda state: state == 'inactive') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryConfigurationState violates a constraint its schema states outside its properties (core/reporting-delivery-config-state.json, root conjunct 2) over deactivated_at, publication_stopped_at, state', + {'fields': 'deactivated_at, publication_stopped_at, state'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_delivery_offering.py b/src/adcp/types/domains/core/reporting_delivery_offering.py index 02ad6a866..ad573b519 100644 --- a/src/adcp/types/domains/core/reporting_delivery_offering.py +++ b/src/adcp/types/domains/core/reporting_delivery_offering.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_offering.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from pydantic import AnyUrl, ConfigDict, Field, model_validator from ..enums import reporting_finality from . import ( @@ -21,6 +21,12 @@ ) +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ReportingProfile(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -219,3 +225,29 @@ class ReportingDeliveryOffering(AdCPBaseModel): description='Managed delivery method for this offering. Omit for a Core (API-delivered) offering: rows flow through existing get_media_buy_delivery and reporting_webhook transports and no destination is involved. Present only when the seller advertises managed_delivery.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingDeliveryOffering: + # Compiled from core/reporting-delivery-offering.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'reconciliation_mode', lambda reconciliation_mode: reconciliation_mode == 'consumer_receipt') if (_c.descend(self, 'feed_purpose', lambda feed_purpose: feed_purpose == 'billing') and _c.sent(self, 'feed_purpose')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryOffering violates a constraint its schema states outside its properties (core/reporting-delivery-offering.json, root conjunct 0) over feed_purpose, reconciliation_mode', + {'fields': 'feed_purpose, reconciliation_mode'}, + ) + if not ( + ((_c.descend(self, 'reporting_profile', lambda reporting_profile: _c.sent(reporting_profile, 'canonicalization_id') and _c.sent(reporting_profile, 'canonicalization_contract_version') and _c.sent(reporting_profile, 'canonicalization_media_type') and _c.sent(reporting_profile, 'canonicalization_uri') and _c.sent(reporting_profile, 'canonicalization_sha256')) and _c.sent(self, 'method')) if (_c.descend(self, 'reconciliation_mode', lambda reconciliation_mode: reconciliation_mode == 'consumer_receipt') and _c.sent(self, 'reconciliation_mode')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingDeliveryOffering violates a constraint its schema states outside its properties (core/reporting-delivery-offering.json, root conjunct 1) over canonicalization_contract_version, canonicalization_id, canonicalization_media_type, canonicalization_sha256, canonicalization_uri, method, reconciliation_mode, reporting_profile', + {'fields': 'canonicalization_contract_version, canonicalization_id, canonicalization_media_type, canonicalization_sha256, canonicalization_uri, method, reconciliation_mode, reporting_profile'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py b/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py index 8976a6482..6843675f9 100644 --- a/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py +++ b/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_ledger_changed_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..enums import reporting_finality from . import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ChangeKind(StrEnum): revision_published = 'revision_published' adjustment_published = 'adjustment_published' @@ -62,3 +68,21 @@ class ReportingLedgerChangedWebhook(AdCPBaseModel): str | None, Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$') ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingLedgerChangedWebhook: + # Compiled from core/reporting-ledger-changed-webhook.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((not (_c.sent(self, 'reporting_adjustment_id') or _c.sent(self, 'adjusts_reporting_revision_id')) and (_c.sent(self, 'reporting_revision_id') and _c.sent(self, 'finality'))) if (_c.descend(self, 'change_kind', lambda change_kind: change_kind == 'revision_published') and _c.sent(self, 'change_kind')) else (not (_c.sent(self, 'reporting_revision_id') or _c.sent(self, 'supersedes_reporting_revision_id') or _c.sent(self, 'finality')) and (_c.sent(self, 'reporting_adjustment_id') and _c.sent(self, 'adjusts_reporting_revision_id')))) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingLedgerChangedWebhook violates a constraint its schema states outside its properties (core/reporting-ledger-changed-webhook.json, root conjunct 0) over adjusts_reporting_revision_id, change_kind, finality, reporting_adjustment_id, reporting_revision_id, supersedes_reporting_revision_id', + {'fields': 'adjusts_reporting_revision_id, change_kind, finality, reporting_adjustment_id, reporting_revision_id, supersedes_reporting_revision_id'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_materialization.py b/src/adcp/types/domains/core/reporting_materialization.py index 995f13884..8c89ba18f 100644 --- a/src/adcp/types/domains/core/reporting_materialization.py +++ b/src/adcp/types/domains/core/reporting_materialization.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_materialization.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import reporting_delivery_offering, reporting_resource, reporting_verification +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Method(StrEnum): file_transfer = 'file_transfer' dataset_share = 'dataset_share' @@ -92,3 +98,61 @@ class ReportingMaterialization(AdCPBaseModel): resource: reporting_resource.ReportingResource | None = None verification: reporting_verification.ReportingVerification | None = None created_at: AwareDatetime + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingMaterialization: + # Compiled from core/reporting-materialization.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'ready_at') and _c.sent(self, 'resource') and _c.sent(self, 'verification')) if (_c.descend(self, 'status', lambda status: status in ['available', 'delivered']) and _c.sent(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingMaterialization violates a constraint its schema states outside its properties (core/reporting-materialization.json, root conjunct 0) over ready_at, resource, status, verification', + {'fields': 'ready_at, resource, status, verification'}, + ) + if not ( + ((_c.sent(self, 'failed_at') and _c.sent(self, 'failure_code')) if (_c.descend(self, 'status', lambda status: status == 'failed') and _c.sent(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingMaterialization violates a constraint its schema states outside its properties (core/reporting-materialization.json, root conjunct 1) over failed_at, failure_code, status', + {'fields': 'failed_at, failure_code, status'}, + ) + if not ( + ((_c.descend(self, 'resource', lambda resource: _c.descend(resource, 'kind', lambda kind: kind == 'manifest')) and _c.descend(self, 'verification', lambda verification: _c.descend(verification, 'physical_checksums', lambda physical_checksums: _c.length(physical_checksums, minimum=1)) and _c.sent(verification, 'physical_checksums'))) if (_c.descend(self, 'method', lambda method: method == 'file_transfer') and _c.sent(self, 'method')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingMaterialization violates a constraint its schema states outside its properties (core/reporting-materialization.json, root conjunct 2) over kind, method, physical_checksums, resource, verification', + {'fields': 'kind, method, physical_checksums, resource, verification'}, + ) + if not ( + ((_c.descend(self, 'resource', lambda resource: _c.descend(resource, 'kind', lambda kind: kind == 'dataset')) and _c.descend(self, 'verification', lambda verification: _c.descend(verification, 'verification_path', lambda verification_path: verification_path == 'representative_consumer'))) if (_c.descend(self, 'method', lambda method: method == 'dataset_share') and _c.sent(self, 'method')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingMaterialization violates a constraint its schema states outside its properties (core/reporting-materialization.json, root conjunct 3) over kind, method, resource, verification, verification_path', + {'fields': 'kind, method, resource, verification, verification_path'}, + ) + if not ( + ((_c.descend(self, 'resource', lambda resource: _c.descend(resource, 'kind', lambda kind: kind == 'warehouse_relation')) and _c.descend(self, 'verification', lambda verification: _c.descend(verification, 'verification_path', lambda verification_path: verification_path == 'destination'))) if (_c.descend(self, 'method', lambda method: method == 'warehouse_materialization') and _c.sent(self, 'method')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingMaterialization violates a constraint its schema states outside its properties (core/reporting-materialization.json, root conjunct 4) over kind, method, resource, verification, verification_path', + {'fields': 'kind, method, resource, verification, verification_path'}, + ) + if not ( + (_c.descend(self, 'verification', lambda verification: _c.descend(verification, 'verification_profile', lambda verification_profile: verification_profile == 'canonical_digest') and (_c.sent(verification, 'canonical_content_digest') and _c.sent(verification, 'verification_profile'))) if ((_c.descend(self, 'feed_purpose', lambda feed_purpose: feed_purpose == 'billing') and _c.descend(self, 'status', lambda status: status in ['available', 'delivered'])) and (_c.sent(self, 'feed_purpose') and _c.sent(self, 'status'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingMaterialization violates a constraint its schema states outside its properties (core/reporting-materialization.json, root conjunct 5) over canonical_content_digest, feed_purpose, status, verification, verification_profile', + {'fields': 'canonical_content_digest, feed_purpose, status, verification, verification_profile'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_obligation.py b/src/adcp/types/domains/core/reporting_obligation.py index bc2a9fa25..b5ea594e3 100644 --- a/src/adcp/types/domains/core/reporting_obligation.py +++ b/src/adcp/types/domains/core/reporting_obligation.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_obligation.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..enums import reporting_finality, reporting_health from . import ( @@ -20,6 +20,12 @@ ) +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Period(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -193,3 +199,93 @@ class ReportingObligation(AdCPBaseModel): description='Minimum time through which at least one verified materialization for a completed obligation remains readable. Managed-delivery only; Core revisions are retained per status_retention_days and readable through the existing API transports.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingObligation: + # Compiled from core/reporting-obligation.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'current_consumer_status_id') if (_c.descend(self, 'consumer_status_count', lambda consumer_status_count: _c.within(consumer_status_count, ge=1)) and _c.sent(self, 'consumer_status_count')) else not (_c.sent(self, 'current_consumer_status_id'))) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingObligation violates a constraint its schema states outside its properties (core/reporting-obligation.json, root conjunct 0) over consumer_status_count, current_consumer_status_id', + {'fields': 'consumer_status_count, current_consumer_status_id'}, + ) + if not ( + ((_c.descend(self, 'issues', lambda issues: _c.length(issues, maximum=0)) and _c.descend(self, 'production_status', lambda production_status: production_status == 'published') and _c.descend(self, 'revision_count', lambda revision_count: _c.within(revision_count, ge=1))) if (_c.descend(self, 'health', lambda health: health in ['healthy', 'complete']) and _c.sent(self, 'health')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingObligation violates a constraint its schema states outside its properties (core/reporting-obligation.json, root conjunct 1) over health, issues, production_status, revision_count', + {'fields': 'health, issues, production_status, revision_count'}, + ) + if not ( + ((_c.descend(self, 'health', lambda health: health == 'action_required') and _c.descend(self, 'issues', lambda issues: _c.some(issues, lambda item: _c.descend(item, 'code', lambda code: code in ['RECEIPT_REQUIRED', 'ADJUSTMENT_RECEIPT_REQUIRED']) and _c.supplied(item, 'code')) and _c.length(issues, minimum=1))) if ((_c.descend(self, 'production_status', lambda production_status: production_status == 'published') and _c.descend(self, 'reconciliation_mode', lambda reconciliation_mode: reconciliation_mode == 'consumer_receipt') and _c.descend(self, 'reconciliation_status', lambda reconciliation_status: reconciliation_status == 'pending')) and (_c.sent(self, 'reconciliation_mode') and _c.sent(self, 'production_status') and _c.sent(self, 'reconciliation_status'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingObligation violates a constraint its schema states outside its properties (core/reporting-obligation.json, root conjunct 2) over code, health, issues, production_status, reconciliation_mode, reconciliation_status', + {'fields': 'code, health, issues, production_status, reconciliation_mode, reconciliation_status'}, + ) + if not ( + ((_c.descend(self, 'health', lambda health: health == 'action_required') and _c.descend(self, 'issues', lambda issues: _c.some(issues, lambda item: _c.descend(item, 'code', lambda code: code in ['RECEIPT_REJECTED', 'ADJUSTMENT_RECEIPT_REJECTED']) and _c.supplied(item, 'code')) and _c.length(issues, minimum=1))) if ((_c.descend(self, 'production_status', lambda production_status: production_status == 'published') and _c.descend(self, 'reconciliation_mode', lambda reconciliation_mode: reconciliation_mode == 'consumer_receipt') and _c.descend(self, 'reconciliation_status', lambda reconciliation_status: reconciliation_status == 'rejected')) and (_c.sent(self, 'reconciliation_mode') and _c.sent(self, 'production_status') and _c.sent(self, 'reconciliation_status'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingObligation violates a constraint its schema states outside its properties (core/reporting-obligation.json, root conjunct 3) over code, health, issues, production_status, reconciliation_mode, reconciliation_status', + {'fields': 'code, health, issues, production_status, reconciliation_mode, reconciliation_status'}, + ) + if not ( + (((_c.descend(self, 'materialization_count', lambda materialization_count: _c.within(materialization_count, ge=1)) and _c.descend(self, 'successful_materialization_count', lambda successful_materialization_count: _c.within(successful_materialization_count, ge=1))) and (_c.sent(self, 'materialization_count') and _c.sent(self, 'successful_materialization_count') and _c.sent(self, 'resource_retained_until'))) if (_c.descend(self, 'health', lambda health: health in ['healthy', 'complete']) and (_c.sent(self, 'health') and _c.sent(self, 'destination_ref'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingObligation violates a constraint its schema states outside its properties (core/reporting-obligation.json, root conjunct 4) over destination_ref, health, materialization_count, resource_retained_until, successful_materialization_count', + {'fields': 'destination_ref, health, materialization_count, resource_retained_until, successful_materialization_count'}, + ) + if not ( + (_c.descend(self, 'revision_count', lambda revision_count: _c.within(revision_count, ge=1)) if (_c.descend(self, 'production_status', lambda production_status: production_status == 'published') and _c.sent(self, 'production_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingObligation violates a constraint its schema states outside its properties (core/reporting-obligation.json, root conjunct 5) over production_status, revision_count', + {'fields': 'production_status, revision_count'}, + ) + if not ( + (_c.descend(self, 'reconciliation_status', lambda reconciliation_status: reconciliation_status == 'not_required') if (_c.descend(self, 'reconciliation_mode', lambda reconciliation_mode: reconciliation_mode == 'delivery_only') and _c.sent(self, 'reconciliation_mode')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingObligation violates a constraint its schema states outside its properties (core/reporting-obligation.json, root conjunct 6) over reconciliation_mode, reconciliation_status', + {'fields': 'reconciliation_mode, reconciliation_status'}, + ) + if not ( + (((_c.descend(self, 'accepted_receipt_count', lambda accepted_receipt_count: _c.within(accepted_receipt_count, ge=1)) and _c.descend(self, 'pending_adjustment_count', lambda pending_adjustment_count: pending_adjustment_count == 0) and _c.descend(self, 'receipt_count', lambda receipt_count: _c.within(receipt_count, ge=1)) and _c.descend(self, 'reconciliation_status', lambda reconciliation_status: reconciliation_status == 'accepted')) and (_c.sent(self, 'accepted_receipt_count') and _c.sent(self, 'receipt_count') and _c.sent(self, 'pending_adjustment_count'))) if ((_c.descend(self, 'health', lambda health: health in ['healthy', 'complete']) and _c.descend(self, 'reconciliation_mode', lambda reconciliation_mode: reconciliation_mode == 'consumer_receipt')) and (_c.sent(self, 'reconciliation_mode') and _c.sent(self, 'health'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingObligation violates a constraint its schema states outside its properties (core/reporting-obligation.json, root conjunct 7) over accepted_receipt_count, health, pending_adjustment_count, receipt_count, reconciliation_mode, reconciliation_status', + {'fields': 'accepted_receipt_count, health, pending_adjustment_count, receipt_count, reconciliation_mode, reconciliation_status'}, + ) + if not ( + (_c.descend(self, 'issues', lambda issues: _c.length(issues, minimum=1)) if (_c.descend(self, 'health', lambda health: health in ['delayed', 'action_required']) and _c.sent(self, 'health')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingObligation violates a constraint its schema states outside its properties (core/reporting-obligation.json, root conjunct 8) over health, issues', + {'fields': 'health, issues'}, + ) + if not ( + (_c.descend(self, 'issues', lambda issues: _c.length(issues, minimum=1)) if (_c.descend(self, 'production_status', lambda production_status: production_status == 'failed') and _c.sent(self, 'production_status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingObligation violates a constraint its schema states outside its properties (core/reporting-obligation.json, root conjunct 9) over issues, production_status', + {'fields': 'issues, production_status'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_receipt.py b/src/adcp/types/domains/core/reporting_receipt.py index 4deba8528..7e3987b60 100644 --- a/src/adcp/types/domains/core/reporting_receipt.py +++ b/src/adcp/types/domains/core/reporting_receipt.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_receipt.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,8 +8,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import ( reporting_canonical_content_digest, @@ -19,6 +18,13 @@ ) +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Status(StrEnum): accepted = 'accepted' rejected = 'rejected' @@ -76,3 +82,45 @@ class ReportingReceipt(AdCPBaseModel): rejection_codes: Annotated[list[RejectionCode] | None, Field(min_length=1)] = None observed_at: AwareDatetime received_at: AwareDatetime | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingReceipt: + # Compiled from core/reporting-receipt.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'rejection_codes') if (_c.descend(self, 'status', lambda status: status == 'rejected') and _c.sent(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingReceipt violates a constraint its schema states outside its properties (core/reporting-receipt.json, root conjunct 0) over rejection_codes, status', + {'fields': 'rejection_codes, status'}, + ) + if not ( + (_c.sent(self, 'observed_canonical_content_digest') if ((_c.descend(self, 'status', lambda status: status == 'accepted') and _c.descend(self, 'verification_profile', lambda verification_profile: verification_profile == 'canonical_digest')) and (_c.sent(self, 'verification_profile') and _c.sent(self, 'status'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingReceipt violates a constraint its schema states outside its properties (core/reporting-receipt.json, root conjunct 1) over observed_canonical_content_digest, status, verification_profile', + {'fields': 'observed_canonical_content_digest, status, verification_profile'}, + ) + if not ( + (_c.sent(self, 'observed_manifest_sha256') if ((_c.descend(self, 'status', lambda status: status == 'accepted') and _c.descend(self, 'verification_profile', lambda verification_profile: verification_profile == 'manifest_checksums')) and (_c.sent(self, 'verification_profile') and _c.sent(self, 'status'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingReceipt violates a constraint its schema states outside its properties (core/reporting-receipt.json, root conjunct 2) over observed_manifest_sha256, status, verification_profile', + {'fields': 'observed_manifest_sha256, status, verification_profile'}, + ) + if not ( + (_c.sent(self, 'observed_native_version_ref') if ((_c.descend(self, 'status', lambda status: status == 'accepted') and _c.descend(self, 'verification_profile', lambda verification_profile: verification_profile == 'native_commit')) and (_c.sent(self, 'verification_profile') and _c.sent(self, 'status'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingReceipt violates a constraint its schema states outside its properties (core/reporting-receipt.json, root conjunct 3) over observed_native_version_ref, status, verification_profile', + {'fields': 'observed_native_version_ref, status, verification_profile'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_reliability_statistics.py b/src/adcp/types/domains/core/reporting_reliability_statistics.py index 53b9e1e70..e54349b19 100644 --- a/src/adcp/types/domains/core/reporting_reliability_statistics.py +++ b/src/adcp/types/domains/core/reporting_reliability_statistics.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_reliability_statistics.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class AdjustmentMagnitudeItem(AdCPBaseModel): @@ -101,3 +107,21 @@ class ReportingReliabilityStatistics(AdCPBaseModel): ), ] = None evidence: Evidence + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingReliabilityStatistics: + # Compiled from core/reporting-reliability-statistics.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'adjustment_latency_seconds') if (_c.descend(self, 'official_revisions_adjusted', lambda official_revisions_adjusted: _c.within(official_revisions_adjusted, ge=1)) and _c.sent(self, 'official_revisions_adjusted')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingReliabilityStatistics violates a constraint its schema states outside its properties (core/reporting-reliability-statistics.json, root conjunct 0) over adjustment_latency_seconds, official_revisions_adjusted', + {'fields': 'adjustment_latency_seconds, official_revisions_adjusted'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_report_definition.py b/src/adcp/types/domains/core/reporting_report_definition.py index 29d118e15..6e9533df2 100644 --- a/src/adcp/types/domains/core/reporting_report_definition.py +++ b/src/adcp/types/domains/core/reporting_report_definition.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_report_definition.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,8 +8,14 @@ from typing import Annotated, Any, Literal from adcp.types.base import AdCPBaseModel +from pydantic import ConfigDict, Field, model_validator + + +from adcp.types import _conditions as _c from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field + + +from pydantic_core import PydanticCustomError class ContractVersion(StrEnum): @@ -177,3 +183,21 @@ class ReportingReportDefinition(AdCPBaseModel): finality_policies: Annotated[ list[FinalityPolicies | FinalityPolicies1 | FinalityPolicies2], Field(min_length=1) ] + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingReportDefinition: + # Compiled from core/reporting-report-definition.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'restatement_policy', lambda restatement_policy: _c.sent(restatement_policy, 'official_correction_mode')) if (_c.descend(self, 'contract_version', lambda contract_version: contract_version == '1.1') and _c.sent(self, 'contract_version')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingReportDefinition violates a constraint its schema states outside its properties (core/reporting-report-definition.json, root conjunct 0) over contract_version, official_correction_mode, restatement_policy', + {'fields': 'contract_version, official_correction_mode, restatement_policy'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_resource.py b/src/adcp/types/domains/core/reporting_resource.py index 8cd4072f7..628367a47 100644 --- a/src/adcp/types/domains/core/reporting_resource.py +++ b/src/adcp/types/domains/core/reporting_resource.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_resource.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import reporting_native_version_ref +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Kind(StrEnum): manifest = 'manifest' dataset = 'dataset' @@ -83,3 +89,29 @@ class ReportingResource(AdCPBaseModel): description='Reader features or format constraints required to consume this resource. Readiness verification MUST use a representative supported reader.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingResource: + # Compiled from core/reporting-resource.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'manifest_version') and _c.sent(self, 'manifest_sha256')) if (_c.descend(self, 'kind', lambda kind: kind == 'manifest') and _c.sent(self, 'kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingResource violates a constraint its schema states outside its properties (core/reporting-resource.json, root conjunct 0) over kind, manifest_sha256, manifest_version', + {'fields': 'kind, manifest_sha256, manifest_version'}, + ) + if not ( + (_c.sent(self, 'native_version_ref') if (_c.descend(self, 'immutability', lambda immutability: immutability == 'native_version') and _c.sent(self, 'immutability')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingResource violates a constraint its schema states outside its properties (core/reporting-resource.json, root conjunct 1) over immutability, native_version_ref', + {'fields': 'immutability, native_version_ref'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_revision.py b/src/adcp/types/domains/core/reporting_revision.py index fd3e216c6..e17ac1f6f 100644 --- a/src/adcp/types/domains/core/reporting_revision.py +++ b/src/adcp/types/domains/core/reporting_revision.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_revision.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator from ..enums import reporting_finality from . import reporting_canonical_content_digest, reporting_control_total, reporting_coverage +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Period(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -167,3 +173,29 @@ class ReportingRevision(AdCPBaseModel): reporting_canonical_content_digest.ReportingCanonicalContentDigest | None ) = None created_at: AwareDatetime + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingRevision: + # Compiled from core/reporting-revision.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'data_through', lambda data_through: _c.json_type(data_through, 'null')) if (_c.descend(self, 'data_through_precision', lambda data_through_precision: data_through_precision == 'unknown') and _c.sent(self, 'data_through_precision')) else _c.descend(self, 'data_through', lambda data_through: _c.json_type(data_through, 'string'))) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingRevision violates a constraint its schema states outside its properties (core/reporting-revision.json, root conjunct 0) over data_through, data_through_precision', + {'fields': 'data_through, data_through_precision'}, + ) + if not ( + ((_c.sent(self, 'finality_basis') and _c.sent(self, 'finality_policy_id') and _c.sent(self, 'finalized_at')) if (_c.descend(self, 'finality', lambda finality: finality == 'official') and _c.sent(self, 'finality')) else not (_c.sent(self, 'finality_basis') or _c.sent(self, 'finality_policy_id') or _c.sent(self, 'finalized_at'))) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingRevision violates a constraint its schema states outside its properties (core/reporting-revision.json, root conjunct 1) over finality, finality_basis, finality_policy_id, finalized_at', + {'fields': 'finality, finality_basis, finality_policy_id, finalized_at'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_schedule.py b/src/adcp/types/domains/core/reporting_schedule.py index 8226a2ba3..2b5422641 100644 --- a/src/adcp/types/domains/core/reporting_schedule.py +++ b/src/adcp/types/domains/core/reporting_schedule.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_schedule.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class ReportingScheduleAlignment(StrEnum): @@ -52,3 +58,21 @@ class ReportingSchedule(AdCPBaseModel): pattern='^P(?=\\d|T)(?=.*\\d)(?:\\d+Y)?(?:\\d+M)?(?:\\d+D)?(?:T(?=\\d)(?:\\d+H)?(?:\\d+M)?(?:\\d+S)?)?$', ), ] + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingSchedule: + # Compiled from core/reporting-schedule.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'period_anchor') and _c.sent(self, 'period_timezone')) if (_c.descend(self, 'alignment', lambda alignment: alignment == 'billing_cycle') and _c.sent(self, 'alignment')) else ((not (_c.sent(self, 'period_anchor')) and _c.sent(self, 'period_timezone')) if (_c.descend(self, 'alignment', lambda alignment: alignment == 'source_timezone') and _c.sent(self, 'alignment')) else not (_c.sent(self, 'period_anchor') or _c.sent(self, 'period_timezone')))) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingSchedule violates a constraint its schema states outside its properties (core/reporting-schedule.json, root conjunct 0) over alignment, period_anchor, period_timezone', + {'fields': 'alignment, period_anchor, period_timezone'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_schedule_offering.py b/src/adcp/types/domains/core/reporting_schedule_offering.py index 26d0a633a..f411fe10e 100644 --- a/src/adcp/types/domains/core/reporting_schedule_offering.py +++ b/src/adcp/types/domains/core/reporting_schedule_offering.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_schedule_offering.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import reporting_schedule +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class PeriodAnchorPolicy(StrEnum): fixed = 'fixed' configurable = 'configurable' @@ -55,3 +61,21 @@ class ReportingScheduleOffering(AdCPBaseModel): pattern='^P(?=\\d|T)(?=.*\\d)(?:\\d+Y)?(?:\\d+M)?(?:\\d+D)?(?:T(?=\\d)(?:\\d+H)?(?:\\d+M)?(?:\\d+S)?)?$' ), ] + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingScheduleOffering: + # Compiled from core/reporting-schedule-offering.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((((_c.sent(self, 'period_anchor') and _c.sent(self, 'period_timezone')) if (_c.descend(self, 'period_anchor_policy', lambda period_anchor_policy: period_anchor_policy == 'fixed') and _c.sent(self, 'period_anchor_policy')) else not (_c.sent(self, 'period_anchor') or _c.sent(self, 'period_timezone'))) and _c.sent(self, 'period_anchor_policy')) if (_c.descend(self, 'alignment', lambda alignment: alignment == 'billing_cycle') and _c.sent(self, 'alignment')) else (((_c.sent(self, 'period_timezone') if (_c.descend(self, 'period_timezone_policy', lambda period_timezone_policy: period_timezone_policy == 'fixed') and _c.sent(self, 'period_timezone_policy')) else not (_c.sent(self, 'period_timezone'))) and not (_c.sent(self, 'period_anchor_policy') or _c.sent(self, 'period_anchor')) and _c.sent(self, 'period_timezone_policy')) if (_c.descend(self, 'alignment', lambda alignment: alignment == 'source_timezone') and _c.sent(self, 'alignment')) else not (_c.sent(self, 'period_anchor_policy') or _c.sent(self, 'period_timezone_policy') or _c.sent(self, 'period_anchor') or _c.sent(self, 'period_timezone')))) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingScheduleOffering violates a constraint its schema states outside its properties (core/reporting-schedule-offering.json, root conjunct 0) over alignment, period_anchor, period_anchor_policy, period_timezone, period_timezone_policy', + {'fields': 'alignment, period_anchor, period_anchor_policy, period_timezone, period_timezone_policy'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_status_changed_webhook.py b/src/adcp/types/domains/core/reporting_status_changed_webhook.py index 3378c62cc..d5ef1568e 100644 --- a/src/adcp/types/domains/core/reporting_status_changed_webhook.py +++ b/src/adcp/types/domains/core/reporting_status_changed_webhook.py @@ -1,20 +1,26 @@ # generated by datamodel-codegen: # filename: core/reporting_status_changed_webhook.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..enums import reporting_health from . import ext as ext_1 from . import reporting_delivery_offering +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class IssueId(ScalarStr): __slots__ = () _constraints = {'max_length': 255, 'min_length': 1, 'pattern': '^[A-Za-z0-9_.:-]{1,255}$'} @@ -81,3 +87,21 @@ class ReportingStatusChangedWebhook(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingStatusChangedWebhook: + # Compiled from core/reporting-status-changed-webhook.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.descend(self, 'issue_ids', lambda issue_ids: _c.length(issue_ids, minimum=1)) and _c.sent(self, 'issue_ids')) if (_c.descend(self, 'health', lambda health: health in ['delayed', 'action_required']) and _c.sent(self, 'health')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingStatusChangedWebhook violates a constraint its schema states outside its properties (core/reporting-status-changed-webhook.json, root conjunct 0) over health, issue_ids', + {'fields': 'health, issue_ids'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_status_issue.py b/src/adcp/types/domains/core/reporting_status_issue.py index 3c4a1b334..cb57fd44b 100644 --- a/src/adcp/types/domains/core/reporting_status_issue.py +++ b/src/adcp/types/domains/core/reporting_status_issue.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_status_issue.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import reporting_coverage, reporting_delivery_offering +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Code(StrEnum): REPORT_OVERDUE = 'REPORT_OVERDUE' PRODUCTION_FAILED = 'PRODUCTION_FAILED' @@ -130,3 +136,21 @@ class ReportingStatusIssue(AdCPBaseModel): period_start: AwareDatetime | None = None period_end: AwareDatetime | None = None expected_at: AwareDatetime | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingStatusIssue: + # Compiled from core/reporting-status-issue.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'opened_at') and _c.sent(self, 'reporting_status_id')) if (_c.descend(self, 'code', lambda code: code == 'CONSUMER_STATUS_MISMATCH') and _c.sent(self, 'code')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingStatusIssue violates a constraint its schema states outside its properties (core/reporting-status-issue.json, root conjunct 0) over code, opened_at, reporting_status_id', + {'fields': 'code, opened_at, reporting_status_id'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_verification.py b/src/adcp/types/domains/core/reporting_verification.py index d10af955d..b8bbd784b 100644 --- a/src/adcp/types/domains/core/reporting_verification.py +++ b/src/adcp/types/domains/core/reporting_verification.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_verification.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import ( reporting_canonical_content_digest, @@ -19,6 +19,12 @@ ) +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class VerificationPath(StrEnum): producer = 'producer' representative_consumer = 'representative_consumer' @@ -102,3 +108,37 @@ class ReportingVerification(AdCPBaseModel): description='Provider-native immutable version evidence observed through the named consumer or destination path.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportingVerification: + # Compiled from core/reporting-verification.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'native_commit_evidence') if (_c.descend(self, 'verification_profile', lambda verification_profile: verification_profile == 'native_commit') and _c.sent(self, 'verification_profile')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingVerification violates a constraint its schema states outside its properties (core/reporting-verification.json, root conjunct 0) over native_commit_evidence, verification_profile', + {'fields': 'native_commit_evidence, verification_profile'}, + ) + if not ( + (_c.sent(self, 'physical_checksums') if (_c.descend(self, 'verification_profile', lambda verification_profile: verification_profile == 'manifest_checksums') and _c.sent(self, 'verification_profile')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingVerification violates a constraint its schema states outside its properties (core/reporting-verification.json, root conjunct 1) over physical_checksums, verification_profile', + {'fields': 'physical_checksums, verification_profile'}, + ) + if not ( + (_c.sent(self, 'canonical_content_digest') if (_c.descend(self, 'verification_profile', lambda verification_profile: verification_profile == 'canonical_digest') and _c.sent(self, 'verification_profile')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportingVerification violates a constraint its schema states outside its properties (core/reporting-verification.json, root conjunct 2) over canonical_content_digest, verification_profile', + {'fields': 'canonical_content_digest, verification_profile'}, + ) + return self diff --git a/src/adcp/types/domains/core/reporting_webhook.py b/src/adcp/types/domains/core/reporting_webhook.py index f20d22909..c4153617e 100644 --- a/src/adcp/types/domains/core/reporting_webhook.py +++ b/src/adcp/types/domains/core/reporting_webhook.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -71,3 +71,12 @@ class ReportingWebhook(AdCPBaseModel): description="Optional list of metrics to include in webhook notifications. If omitted, all available metrics are included; an empty array has the same meaning as omission (it does not narrow to impressions and spend only). impressions and spend are always included regardless of this list. Must be a subset of the product's available_metrics. Subset evaluation and leaf resolution follow `enums/available-metric.json`: a numeric leaf may be covered by its container, while each structured distribution must be explicitly available. Requesting any nested identity selects its canonical carrier inside the payload's nested object. Same narrowing semantics as get_media_buy_delivery's requested_metrics (which additionally requires at least one entry when present)." ), ] = None + operation_id: Annotated[ + str | None, + Field( + description='Opt-in AdCP 3.2 reporting-stream correlation extension pending adcp#7885. Cooperating sellers store and echo the buyer-supplied value verbatim in media_buy_delivery webhook payloads. Confirm seller support before use; registrations without a value cannot enable this interim delivery path.', + max_length=255, + min_length=1, + pattern='^[A-Za-z0-9_.:-]{1,255}$', + ), + ] = None diff --git a/src/adcp/types/domains/core/requirements/daast_asset_requirements.py b/src/adcp/types/domains/core/requirements/daast_asset_requirements.py index 1b591a10b..408aa6bcf 100644 --- a/src/adcp/types/domains/core/requirements/daast_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/daast_asset_requirements.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: core/requirements/daast_asset_requirements.json -# timestamp: 2026-08-28T20:03:29+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ...enums import daast_version as daast_version_1 from .. import daast_tracker_constraints +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class DaastAssetRequirements(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -28,3 +34,21 @@ class DaastAssetRequirements(AdCPBaseModel): daast_tracker_constraints.DaastVersions | None, Field(description='Accepted DAAST version set for this asset requirement.'), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> DaastAssetRequirements: + # Compiled from core/requirements/daast-asset-requirements.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'daast_version') and _c.sent(self, 'daast_versions')) + ): + raise PydanticCustomError( + 'allOf', + 'DaastAssetRequirements violates a constraint its schema states outside its properties (core/requirements/daast-asset-requirements.json, root conjunct 0) over daast_version, daast_versions', + {'fields': 'daast_version, daast_versions'}, + ) + return self diff --git a/src/adcp/types/domains/core/requirements/image_asset_requirements.py b/src/adcp/types/domains/core/requirements/image_asset_requirements.py index f0a643f4e..30478de2a 100644 --- a/src/adcp/types/domains/core/requirements/image_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/image_asset_requirements.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/requirements/image_asset_requirements.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarFloat -from pydantic import ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ...enums import dimension_unit +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarFloat + + +from pydantic_core import PydanticCustomError + + class PixelRatio(ScalarFloat): __slots__ = () _constraints = {'gt': 0.0} @@ -157,3 +163,21 @@ class ImageAssetRequirements(AdCPBaseModel): gt=0, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ImageAssetRequirements: + # Compiled from core/requirements/image-asset-requirements.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'unit') if _c.sent(self, 'min_dpi') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ImageAssetRequirements violates a constraint its schema states outside its properties (core/requirements/image-asset-requirements.json, root conjunct 0) over min_dpi, unit', + {'fields': 'min_dpi, unit'}, + ) + return self diff --git a/src/adcp/types/domains/core/requirements/vast_asset_requirements.py b/src/adcp/types/domains/core/requirements/vast_asset_requirements.py index f778813a2..2903209f8 100644 --- a/src/adcp/types/domains/core/requirements/vast_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/vast_asset_requirements.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: core/requirements/vast_asset_requirements.json -# timestamp: 2026-08-28T20:03:29+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ...enums import vast_version as vast_version_1 from .. import vast_media_file_requirements +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class VastAssetRequirements(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -34,3 +40,21 @@ class VastAssetRequirements(AdCPBaseModel): description='Technical acceptance constraints for the alternative MediaFile renditions in each applicable resolved InLine linear creative.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> VastAssetRequirements: + # Compiled from core/requirements/vast-asset-requirements.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'vast_version') and _c.sent(self, 'vast_versions')) + ): + raise PydanticCustomError( + 'allOf', + 'VastAssetRequirements violates a constraint its schema states outside its properties (core/requirements/vast-asset-requirements.json, root conjunct 0) over vast_version, vast_versions', + {'fields': 'vast_version, vast_versions'}, + ) + return self diff --git a/src/adcp/types/domains/core/rights_constraint.py b/src/adcp/types/domains/core/rights_constraint.py index d5838dd94..fd7b9205b 100644 --- a/src/adcp/types/domains/core/rights_constraint.py +++ b/src/adcp/types/domains/core/rights_constraint.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/rights_constraint.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,8 +8,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, model_validator from ..enums import right_type as right_type_1 from ..enums import right_use @@ -18,6 +17,13 @@ from .attestation_reference import AttestationReference +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class RightsAgent(AdCPBaseModel): url: Annotated[AnyUrl, Field(description='MCP endpoint URL of the rights agent')] id: Annotated[str, Field(description='Agent identifier')] @@ -316,3 +322,21 @@ class RightsConstraint(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> RightsConstraint: + # Compiled from core/rights-constraint.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'rights_holder') and _c.sent(self, 'grant_status') and _c.sent(self, 'content_digest')) if _c.sent(self, 'attestation_refs') else True) + ): + raise PydanticCustomError( + 'allOf', + 'RightsConstraint violates a constraint its schema states outside its properties (core/rights-constraint.json, root conjunct 0) over attestation_refs, content_digest, grant_status, rights_holder', + {'fields': 'attestation_refs, content_digest, grant_status, rights_holder'}, + ) + return self diff --git a/src/adcp/types/domains/core/signal_coverage_forecast.py b/src/adcp/types/domains/core/signal_coverage_forecast.py index 41a01b7c3..e09f39a8e 100644 --- a/src/adcp/types/domains/core/signal_coverage_forecast.py +++ b/src/adcp/types/domains/core/signal_coverage_forecast.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/signal_coverage_forecast.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,8 +8,7 @@ from typing import Annotated, Dict, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..enums import forecast_method from . import date_range as date_range_1 @@ -17,6 +16,13 @@ from . import forecast_point, forecast_point_dimensions, forecast_range +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class CoverageRate(forecast_point.CoverageRate): low: Annotated[ StrictFloat | None, @@ -180,3 +186,21 @@ class SignalCoverageForecast(AdCPBaseModel): AwareDatetime | None, Field(description='When this coverage forecast expires.') ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> SignalCoverageForecast: + # Compiled from core/signal-coverage-forecast.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'scope', lambda scope: _c.sent(scope, 'product_id')) if (_c.descend(self, 'scope', lambda scope: _c.descend(scope, 'kind', lambda kind: kind == 'product') and _c.sent(scope, 'kind')) and _c.sent(self, 'scope')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalCoverageForecast violates a constraint its schema states outside its properties (core/signal-coverage-forecast.json, root conjunct 0) over kind, product_id, scope', + {'fields': 'kind, product_id, scope'}, + ) + return self diff --git a/src/adcp/types/domains/core/signal_definition.py b/src/adcp/types/domains/core/signal_definition.py index 1aedabd51..18beb021b 100644 --- a/src/adcp/types/domains/core/signal_definition.py +++ b/src/adcp/types/domains/core/signal_definition.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/signal_definition.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,8 +8,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, StrictFloat +from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, StrictFloat, model_validator from ..enums import audience_resolution_method, audience_subject_type from ..enums import consent_basis as consent_basis_1 @@ -18,6 +17,13 @@ from . import signal_modeling_disclosure +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Tag(ScalarStr): __slots__ = () _constraints = {'pattern': '^[a-z0-9_-]+$'} @@ -543,3 +549,61 @@ class SignalDefinition(AdCPBaseModel): description='IAB Data Transparency Standard version this signal definition self-attests as satisfying, when applicable.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> SignalDefinition: + # Compiled from core/signal-definition.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'taxonomy', lambda taxonomy: _c.sent(taxonomy, 'value_mappings')) if (_c.descend(self, 'value_type', lambda value_type: value_type == 'categorical') and (_c.sent(self, 'value_type') and _c.sent(self, 'taxonomy'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalDefinition violates a constraint its schema states outside its properties (core/signal-definition.json, root conjunct 0) over taxonomy, value_mappings, value_type', + {'fields': 'taxonomy, value_mappings, value_type'}, + ) + if not ( + (_c.sent(self, 'originating_domain') if (_c.descend(self, 'audience_scope', lambda audience_scope: audience_scope == 'single_domain') and _c.sent(self, 'audience_scope')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalDefinition violates a constraint its schema states outside its properties (core/signal-definition.json, root conjunct 1) over audience_scope, originating_domain', + {'fields': 'audience_scope, originating_domain'}, + ) + if not ( + (_c.sent(self, 'modeling') if (_c.descend(self, 'methodology', lambda methodology: methodology == 'modeled') and _c.sent(self, 'methodology')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalDefinition violates a constraint its schema states outside its properties (core/signal-definition.json, root conjunct 2) over methodology, modeling', + {'fields': 'methodology, modeling'}, + ) + if not ( + (_c.sent(self, 'modeling') if (_c.descend(self, 'audience_expansion', lambda audience_expansion: audience_expansion == True) and _c.sent(self, 'audience_expansion')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalDefinition violates a constraint its schema states outside its properties (core/signal-definition.json, root conjunct 3) over audience_expansion, modeling', + {'fields': 'audience_expansion, modeling'}, + ) + if not ( + (_c.sent(self, 'onboarder') if (_c.descend(self, 'data_sources', lambda data_sources: _c.some(data_sources, lambda item: item in ['offline_survey', 'public_record_census', 'public_record_voter_file', 'public_record_other', 'offline_transaction'])) and _c.sent(self, 'data_sources')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalDefinition violates a constraint its schema states outside its properties (core/signal-definition.json, root conjunct 4) over data_sources, onboarder', + {'fields': 'data_sources, onboarder'}, + ) + if not ( + ((_c.descend(self, 'restricted_attributes', lambda restricted_attributes: _c.some(restricted_attributes, lambda item: item == 'age')) and _c.sent(self, 'restricted_attributes')) if _c.sent(self, 'demographic_predicate') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalDefinition violates a constraint its schema states outside its properties (core/signal-definition.json, root conjunct 5) over demographic_predicate, restricted_attributes', + {'fields': 'demographic_predicate, restricted_attributes'}, + ) + return self diff --git a/src/adcp/types/domains/core/signal_definition_enrichment.py b/src/adcp/types/domains/core/signal_definition_enrichment.py index 2151a3a24..51c2581c5 100644 --- a/src/adcp/types/domains/core/signal_definition_enrichment.py +++ b/src/adcp/types/domains/core/signal_definition_enrichment.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/signal_definition_enrichment.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,8 +8,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool +from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, model_validator from ..enums import consent_basis as consent_basis_1 from ..enums import restricted_attribute @@ -17,6 +16,13 @@ from . import signal_modeling_disclosure +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Value(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -281,3 +287,21 @@ class SignalDefinitionEnrichment(AdCPBaseModel): ), ] = None dts_compliant_version: str | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> SignalDefinitionEnrichment: + # Compiled from core/signal-definition-enrichment.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.descend(self, 'restricted_attributes', lambda restricted_attributes: _c.some(restricted_attributes, lambda item: item == 'age')) and _c.sent(self, 'restricted_attributes')) if _c.sent(self, 'demographic_predicate') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalDefinitionEnrichment violates a constraint its schema states outside its properties (core/signal-definition-enrichment.json, root conjunct 0) over demographic_predicate, restricted_attributes', + {'fields': 'demographic_predicate, restricted_attributes'}, + ) + return self diff --git a/src/adcp/types/domains/core/signal_listing.py b/src/adcp/types/domains/core/signal_listing.py index c2c4db2db..e1c191b57 100644 --- a/src/adcp/types/domains/core/signal_listing.py +++ b/src/adcp/types/domains/core/signal_listing.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: core/signal_listing.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat, model_validator @@ -15,6 +15,9 @@ from . import signal_ref as signal_ref_1 +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -97,19 +100,45 @@ class SignalListing(AdCPBaseModel): description='Machine-readable demographic meaning. For product-local signals this listing is authoritative; for data-provider and signal-source refs, any projected value MUST match the referenced authoritative definition. Signal names alone never establish demographic semantics.' ), ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'signal_id', 'signal_ref'}) @model_validator(mode='after') def _require_schema_required_group(self) -> SignalListing: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('signal_ref',), ('signal_id',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'SignalListing requires at least one of these field groups: signal_ref | signal_id', - ) + # Compiled from core/signal-listing.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # signal_ref + ( + _c.sent(self, 'signal_ref') + ) + # signal_id + or ( + _c.sent(self, 'signal_id') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'SignalListing requires at least one of these field groups: signal_ref | signal_id', + ) + if not ( + ((_c.sent(self, 'name') and _c.sent(self, 'value_type')) if (_c.descend(self, 'signal_ref', lambda signal_ref: _c.descend(signal_ref, 'scope', lambda scope: scope == 'product') and _c.sent(signal_ref, 'scope') and _c.json_type(signal_ref, 'object')) and _c.sent(self, 'signal_ref')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalListing violates a constraint its schema states outside its properties (core/signal-listing.json, root conjunct 0) over name, scope, signal_ref, value_type', + {'fields': 'name, scope, signal_ref, value_type'}, + ) + if not ( + ((_c.descend(self, 'restricted_attributes', lambda restricted_attributes: _c.some(restricted_attributes, lambda item: item == 'age')) and _c.sent(self, 'restricted_attributes')) if _c.sent(self, 'demographic_predicate') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalListing violates a constraint its schema states outside its properties (core/signal-listing.json, root conjunct 1) over demographic_predicate, restricted_attributes', + {'fields': 'demographic_predicate, restricted_attributes'}, + ) + return self diff --git a/src/adcp/types/domains/core/signal_modeling_disclosure.py b/src/adcp/types/domains/core/signal_modeling_disclosure.py index 31be34976..6a333df58 100644 --- a/src/adcp/types/domains/core/signal_modeling_disclosure.py +++ b/src/adcp/types/domains/core/signal_modeling_disclosure.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/signal_modeling_disclosure.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from pydantic import AnyUrl, ConfigDict, Field, StrictBool, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class Audience(StrEnum): @@ -75,3 +81,21 @@ class SignalModelingDisclosure(AdCPBaseModel): max_length=2000, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> SignalModelingDisclosure: + # Compiled from core/signal-modeling-disclosure.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'jurisdictions') if _c.descend(self, 'required', lambda required: required == True) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SignalModelingDisclosure violates a constraint its schema states outside its properties (core/signal-modeling-disclosure.json, root conjunct 0) over jurisdictions, required', + {'fields': 'jurisdictions, required'}, + ) + return self diff --git a/src/adcp/types/domains/core/targeting_overlay_requirements.py b/src/adcp/types/domains/core/targeting_overlay_requirements.py index 9ae9b0230..3f0fa5436 100644 --- a/src/adcp/types/domains/core/targeting_overlay_requirements.py +++ b/src/adcp/types/domains/core/targeting_overlay_requirements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/targeting_overlay_requirements.json -# timestamp: 2026-10-03T00:35:15+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Any, Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, RootModel +from pydantic import ConfigDict, Field, RootModel, model_validator from ..enums import browser_family, daypart_timezone_mode, match_type, metro_system, transport_mode from . import ext as ext_1 @@ -20,6 +20,12 @@ ) +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Required(RootModel[Literal[True]]): root: Literal[True] @@ -166,3 +172,28 @@ class TargetingOverlayRequirements(AdCPBaseModel): keyword_targets: KeywordRequirement | None = None negative_keywords: KeywordRequirement | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> TargetingOverlayRequirements: + # Compiled from core/targeting-overlay-requirements.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'frequency_cap') and _c.sent(self, 'frequency_cap_support')) + ): + raise PydanticCustomError( + 'allOf', + 'TargetingOverlayRequirements violates a constraint its schema states outside its properties (core/targeting-overlay-requirements.json, root conjunct 0) over frequency_cap, frequency_cap_support', + {'fields': 'frequency_cap, frequency_cap_support'}, + ) + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'TargetingOverlayRequirements violates a constraint its schema states outside its properties (core/targeting-overlay-requirements.json, root conjunct 1)', + ) + return self diff --git a/src/adcp/types/domains/core/targeting_overlay_support.py b/src/adcp/types/domains/core/targeting_overlay_support.py index 0e7756166..c291db525 100644 --- a/src/adcp/types/domains/core/targeting_overlay_support.py +++ b/src/adcp/types/domains/core/targeting_overlay_support.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: core/targeting_overlay_support.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Any, Annotated, Literal from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, SchemaInt -from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, RootModel, StringConstraints +from pydantic import ConfigDict, Field, RootModel, StringConstraints, model_validator from ..enums import browser_family, daypart_timezone_mode, match_type, metro_system, transport_mode from . import ext as ext_1 @@ -22,6 +21,13 @@ ) +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class PlacementSelection(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -276,3 +282,28 @@ class TargetingOverlaySupport(AdCPBaseModel): keyword_targets: KeywordSupport | None = None negative_keywords: KeywordSupport | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> TargetingOverlaySupport: + # Compiled from core/targeting-overlay-support.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'frequency_cap') and _c.sent(self, 'frequency_cap_support')) + ): + raise PydanticCustomError( + 'allOf', + 'TargetingOverlaySupport violates a constraint its schema states outside its properties (core/targeting-overlay-support.json, root conjunct 0) over frequency_cap, frequency_cap_support', + {'fields': 'frequency_cap, frequency_cap_support'}, + ) + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'TargetingOverlaySupport violates a constraint its schema states outside its properties (core/targeting-overlay-support.json, root conjunct 1)', + ) + return self diff --git a/src/adcp/types/domains/core/tasks_get_response.py b/src/adcp/types/domains/core/tasks_get_response.py index 99e8c8f85..b3240b0e5 100644 --- a/src/adcp/types/domains/core/tasks_get_response.py +++ b/src/adcp/types/domains/core/tasks_get_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/tasks_get_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..enums import adcp_protocol, task_status from ..enums import task_type as task_type_1 @@ -18,6 +18,12 @@ from .version_envelope import AdcpVersionEnvelope +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Progress(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -120,3 +126,21 @@ class TasksGetResponse(AdcpVersionEnvelope, ProtocolEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> TasksGetResponse: + # Compiled from core/tasks-get-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'result', lambda result: ((_c.descend(result, 'adcp_major_version', lambda adcp_major_version: _c.within(adcp_major_version, le=99) and _c.within(adcp_major_version, ge=1) and _c.json_type(adcp_major_version, 'integer')) and _c.descend(result, 'adcp_version', lambda adcp_version: _c.matches(adcp_version, '^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$') and _c.json_type(adcp_version, 'string'))) and (not (_c.supplied(result, 'task_status') or _c.supplied(result, 'response_status')) and (_c.descend(result, 'adcp_error', lambda adcp_error: (not (_c.supplied(adcp_error, 'buyer_reason')) or _c.supplied(adcp_error, 'recovery')) and (_c.descend(adcp_error, 'buyer_reason', lambda buyer_reason: (_c.descend(buyer_reason, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(buyer_reason, 'message', lambda message: _c.length(message, minimum=1) and _c.json_type(message, 'string'))) and (_c.supplied(buyer_reason, 'code') and _c.supplied(buyer_reason, 'message')) and _c.json_type(buyer_reason, 'object')) and _c.descend(adcp_error, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(adcp_error, 'details', lambda details: _c.json_type(details, 'object')) and _c.descend(adcp_error, 'field', lambda field: _c.json_type(field, 'string')) and _c.descend(adcp_error, 'issues', lambda issues: _c.every(issues, lambda item: (_c.descend(item, 'discriminator', lambda discriminator: _c.every(discriminator, lambda item: _c.only(item, ['property_name', 'value']) and (_c.descend(item, 'property_name', lambda property_name: _c.json_type(property_name, 'string')) and _c.descend(item, 'value', lambda value: _c.json_type(value, 'string', 'number', 'boolean', 'null'))) and (_c.supplied(item, 'property_name') and _c.supplied(item, 'value')) and _c.json_type(item, 'object')) and _c.json_type(discriminator, 'array')) and _c.descend(item, 'keyword', lambda keyword: _c.json_type(keyword, 'string')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'pointer', lambda pointer: _c.json_type(pointer, 'string')) and _c.descend(item, 'schemaPath', lambda schemaPath: _c.json_type(schemaPath, 'string')) and _c.descend(item, 'schema_id', lambda schema_id: _c.json_type(schema_id, 'string'))) and (_c.supplied(item, 'pointer') and _c.supplied(item, 'message') and _c.supplied(item, 'keyword')) and _c.json_type(item, 'object')) and _c.json_type(issues, 'array')) and _c.descend(adcp_error, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(adcp_error, 'recovery', lambda recovery: recovery in ['transient', 'correctable', 'terminal'] and _c.json_type(recovery, 'string')) and _c.descend(adcp_error, 'retry_after', lambda retry_after: _c.within(retry_after, le=3600) and _c.within(retry_after, ge=1) and _c.json_type(retry_after, 'number')) and _c.descend(adcp_error, 'sdk_id', lambda sdk_id: _c.json_type(sdk_id, 'string')) and _c.descend(adcp_error, 'source', lambda source: source in ['producer', 'sdk'] and _c.json_type(source, 'string')) and _c.descend(adcp_error, 'suggestion', lambda suggestion: _c.json_type(suggestion, 'string'))) and (_c.supplied(adcp_error, 'code') and _c.supplied(adcp_error, 'message')) and _c.json_type(adcp_error, 'object')) and _c.descend(result, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(result, 'context_id', lambda context_id: _c.json_type(context_id, 'string')) and _c.descend(result, 'governance_context', lambda governance_context: _c.length(governance_context, maximum=4096) and _c.length(governance_context, minimum=1) and _c.matches(governance_context, '^[\\x20-\\x7E]+$') and _c.json_type(governance_context, 'string')) and _c.descend(result, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(result, 'payload', lambda payload: _c.json_type(payload, 'object')) and _c.descend(result, 'push_notification_config', lambda push_notification_config: (_c.descend(push_notification_config, 'authentication', lambda authentication: _c.only(authentication, ['credentials', 'schemes']) and (_c.descend(authentication, 'credentials', lambda credentials: _c.length(credentials, minimum=32) and _c.json_type(credentials, 'string')) and _c.descend(authentication, 'schemes', lambda schemes: _c.every(schemes, lambda item: item in ['Bearer', 'HMAC-SHA256'] and _c.json_type(item, 'string')) and _c.length(schemes, maximum=1) and _c.length(schemes, minimum=1) and _c.json_type(schemes, 'array'))) and (_c.supplied(authentication, 'schemes') and _c.supplied(authentication, 'credentials')) and _c.json_type(authentication, 'object')) and _c.descend(push_notification_config, 'operation_id', lambda operation_id: _c.length(operation_id, maximum=255) and _c.length(operation_id, minimum=1) and _c.matches(operation_id, '^[A-Za-z0-9_.:-]{1,255}$') and _c.json_type(operation_id, 'string')) and _c.descend(push_notification_config, 'token', lambda token: _c.length(token, maximum=4096) and _c.length(token, minimum=16) and _c.json_type(token, 'string')) and _c.descend(push_notification_config, 'url', lambda url: _c.json_type(url, 'string'))) and _c.supplied(push_notification_config, 'url') and _c.json_type(push_notification_config, 'object')) and _c.descend(result, 'replayed', lambda replayed: _c.json_type(replayed, 'boolean')) and _c.descend(result, 'status', lambda status: status in ['submitted', 'working', 'input-required', 'completed', 'canceled', 'failed', 'rejected', 'auth-required', 'unknown'] and _c.json_type(status, 'string')) and _c.descend(result, 'task_id', lambda task_id: _c.json_type(task_id, 'string')) and _c.descend(result, 'timestamp', lambda timestamp: _c.json_type(timestamp, 'string'))) and _c.supplied(result, 'status'))) and not (_c.supplied(result, 'products') or _c.supplied(result, 'proposals') or _c.supplied(result, 'errors') or _c.supplied(result, 'adcp_error') or _c.supplied(result, 'incomplete') or _c.supplied(result, 'cache_scope') or _c.supplied(result, 'filter_diagnostics') or _c.supplied(result, 'refinement_applied') or _c.supplied(result, 'unchanged') or _c.supplied(result, 'wholesale_feed_version') or _c.supplied(result, 'pricing_version') or _c.supplied(result, 'pagination')) and (_c.descend(result, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(result, 'ext', lambda ext: _c.json_type(ext, 'object')) and _c.descend(result, 'reason', lambda reason: _c.length(reason, maximum=2000) and _c.length(reason, minimum=1) and _c.json_type(reason, 'string')) and _c.descend(result, 'status', lambda status: status == 'rejected' and _c.json_type(status, 'string')) and _c.descend(result, 'suggestions', lambda suggestions: _c.every(suggestions, lambda item: _c.length(item, maximum=1000) and _c.length(item, minimum=1) and _c.json_type(item, 'string')) and _c.length(suggestions, maximum=20) and _c.length(suggestions, minimum=1) and _c.json_type(suggestions, 'array'))) and (_c.supplied(result, 'status') and _c.supplied(result, 'reason'))) if ((_c.descend(self, 'result', lambda result: _c.descend(result, 'status', lambda status: status == 'rejected') and _c.supplied(result, 'status')) and _c.descend(self, 'task_type', lambda task_type: task_type == 'get_products')) and (_c.sent(self, 'task_type') and _c.sent(self, 'result'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'TasksGetResponse violates a constraint its schema states outside its properties (core/tasks-get-response.json, root conjunct 0) over adcp_error, adcp_major_version, adcp_version, authentication, buyer_reason, cache_scope, code, context, context_id, credentials, details, discriminator, errors, ext, field, filter_diagnostics, governance_context, incomplete, issues, keyword, message, operation_id, pagination, payload, pointer, pricing_version, products, property_name, proposals, push_notification_config, reason, recovery, refinement_applied, replayed, response_status, result, retry_after, schemaPath, schema_id, schemes, sdk_id, source, status, suggestion, suggestions, task_id, task_status, task_type, timestamp, token, unchanged, url, value, wholesale_feed_version', + {'fields': 'adcp_error, adcp_major_version, adcp_version, authentication, buyer_reason, cache_scope, code, context, context_id, credentials, details, discriminator, errors, ext, field, filter_diagnostics, governance_context, incomplete, issues, keyword, message, operation_id, pagination, payload, pointer, pricing_version, products, property_name, proposals, push_notification_config, reason, recovery, refinement_applied, replayed, response_status, result, retry_after, schemaPath, schema_id, schemes, sdk_id, source, status, suggestion, suggestions, task_id, task_status, task_type, timestamp, token, unchanged, url, value, wholesale_feed_version'}, + ) + return self diff --git a/src/adcp/types/domains/core/transformer.py b/src/adcp/types/domains/core/transformer.py index 59666ebe1..bc15b75a0 100644 --- a/src/adcp/types/domains/core/transformer.py +++ b/src/adcp/types/domains/core/transformer.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: core/transformer.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum -from typing import Annotated, Any, Literal +from typing import Annotated, Any, ClassVar, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, ConfigDict, Field, StrictBool, model_validator from ..enums import channels @@ -34,6 +33,10 @@ from . import transformer_param, vendor_pricing_option +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + from pydantic_core import PydanticCustomError @@ -1862,19 +1865,29 @@ def _require_output_format_declaration(self) -> Transformer: 'one of output_capability_ids or deprecated output_format_ids is required' ) return self + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'output_capability_ids', 'output_format_ids'}) @model_validator(mode='after') def _require_schema_required_group(self) -> Transformer: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('output_capability_ids',), ('output_format_ids',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'Transformer requires at least one of these field groups: output_capability_ids | output_format_ids', - ) + # Compiled from core/transformer.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # Canonical transformer outputs + ( + _c.sent(self, 'output_capability_ids') + ) + # Named-format transformer outputs + or ( + _c.sent(self, 'output_format_ids') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'Transformer requires at least one of these field groups: Canonical transformer outputs | Named-format transformer outputs', + ) + return self diff --git a/src/adcp/types/domains/core/transformer_param.py b/src/adcp/types/domains/core/transformer_param.py index 2651bd37c..497035720 100644 --- a/src/adcp/types/domains/core/transformer_param.py +++ b/src/adcp/types/domains/core/transformer_param.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/transformer_param.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class Type(StrEnum): @@ -109,3 +115,37 @@ class TransformerParam(AdCPBaseModel): description: Annotated[ str | None, Field(description='Human-readable explanation of what this knob does.') ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> TransformerParam: + # Compiled from core/transformer-param.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'allowed_values') if (_c.descend(self, 'value_source', lambda value_source: value_source == 'inline') and _c.sent(self, 'value_source')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'TransformerParam violates a constraint its schema states outside its properties (core/transformer-param.json, root conjunct 0) over allowed_values, value_source', + {'fields': 'allowed_values, value_source'}, + ) + if not ( + ((_c.sent(self, 'minimum') and _c.sent(self, 'maximum')) if (_c.descend(self, 'value_source', lambda value_source: value_source == 'range') and _c.sent(self, 'value_source')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'TransformerParam violates a constraint its schema states outside its properties (core/transformer-param.json, root conjunct 1) over maximum, minimum, value_source', + {'fields': 'maximum, minimum, value_source'}, + ) + if not ( + ((not (_c.sent(self, 'allowed_values') or _c.sent(self, 'minimum') or _c.sent(self, 'maximum') or _c.sent(self, 'options') or _c.sent(self, 'options_cursor')) and _c.descend(self, 'type', lambda type: type == 'string')) if (_c.descend(self, 'value_source', lambda value_source: value_source == 'free_text') and _c.sent(self, 'value_source')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'TransformerParam violates a constraint its schema states outside its properties (core/transformer-param.json, root conjunct 2) over allowed_values, maximum, minimum, options, options_cursor, type, value_source', + {'fields': 'allowed_values, maximum, minimum, options, options_cursor, type, value_source'}, + ) + return self diff --git a/src/adcp/types/domains/core/user_match.py b/src/adcp/types/domains/core/user_match.py index 857fd05e9..78f893151 100644 --- a/src/adcp/types/domains/core/user_match.py +++ b/src/adcp/types/domains/core/user_match.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: core/user_match.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, model_validator @@ -13,6 +13,9 @@ from . import ext as ext_1 +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -60,19 +63,41 @@ class UserMatch(AdCPBaseModel): str | None, Field(description='Client user agent string for probabilistic matching') ] = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'click_id', 'client_ip', 'client_user_agent', 'hashed_email', 'hashed_phone', 'uids'}) @model_validator(mode='after') def _require_schema_required_group(self) -> UserMatch: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('uids',), ('hashed_email',), ('hashed_phone',), ('click_id',), ('client_ip', 'client_user_agent'),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'UserMatch requires at least one of these field groups: uids | hashed_email | hashed_phone | click_id | client_ip+client_user_agent', - ) + # Compiled from core/user-match.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # uids + ( + _c.sent(self, 'uids') + ) + # hashed_email + or ( + _c.sent(self, 'hashed_email') + ) + # hashed_phone + or ( + _c.sent(self, 'hashed_phone') + ) + # click_id + or ( + _c.sent(self, 'click_id') + ) + # client_ip+client_user_agent + or ( + _c.sent(self, 'client_ip') and _c.sent(self, 'client_user_agent') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'UserMatch requires at least one of these field groups: uids | hashed_email | hashed_phone | click_id | client_ip+client_user_agent', + ) + return self diff --git a/src/adcp/types/domains/core/vast_tracker_constraints.py b/src/adcp/types/domains/core/vast_tracker_constraints.py index 94f3664d0..12126e66f 100644 --- a/src/adcp/types/domains/core/vast_tracker_constraints.py +++ b/src/adcp/types/domains/core/vast_tracker_constraints.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/vast_tracker_constraints.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import Field, RootModel +from pydantic import Field, RootModel, model_validator from ..enums import vast_version +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class VastEvent(StrEnum): creativeView = 'creativeView' loaded = 'loaded' @@ -64,3 +70,101 @@ class VastTrackerConstraints(AdCPBaseModel): target: VastTarget | None = VastTarget.linear offset: VastOffset | None = None vast_versions: VastVersions | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> VastTrackerConstraints: + # Compiled from core/vast-tracker-constraints.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'vast_event', lambda vast_event: vast_event in ['creativeView', 'loaded', 'start', 'firstQuartile', 'midpoint', 'thirdQuartile', 'complete', 'mute', 'unmute', 'pause', 'resume', 'rewind', 'skip', 'playerExpand', 'playerCollapse', 'fullscreen', 'exitFullscreen', 'progress', 'otherAdInteraction', 'interactiveStart', 'closeLinear']) if (not (_c.sent(self, 'target')) or (_c.descend(self, 'target', lambda target: target == 'linear') and _c.sent(self, 'target'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 0) over target, vast_event', + {'fields': 'target, vast_event'}, + ) + if not ( + (_c.descend(self, 'vast_event', lambda vast_event: vast_event in ['creativeView', 'acceptInvitation', 'adExpand', 'adCollapse', 'minimize', 'overlayViewDuration', 'otherAdInteraction', 'close']) if (_c.descend(self, 'target', lambda target: target == 'non_linear') and _c.sent(self, 'target')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 1) over target, vast_event', + {'fields': 'target, vast_event'}, + ) + if not ( + (_c.descend(self, 'vast_event', lambda vast_event: vast_event == 'creativeView') if (_c.descend(self, 'target', lambda target: target == 'companion') and _c.sent(self, 'target')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 2) over target, vast_event', + {'fields': 'target, vast_event'}, + ) + if not ( + (_c.sent(self, 'offset') if (_c.descend(self, 'vast_event', lambda vast_event: vast_event == 'progress') and _c.sent(self, 'vast_event')) else not (_c.sent(self, 'offset'))) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 3) over offset, vast_event', + {'fields': 'offset, vast_event'}, + ) + if not ( + (_c.descend(self, 'vast_versions', lambda vast_versions: _c.every(vast_versions, lambda item: item in ['3.0', '4.0', '4.1', '4.2', '4.3'])) if (_c.descend(self, 'vast_event', lambda vast_event: vast_event in ['skip', 'progress']) and _c.sent(self, 'vast_event')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 4) over vast_event, vast_versions', + {'fields': 'vast_event, vast_versions'}, + ) + if not ( + (_c.descend(self, 'vast_versions', lambda vast_versions: _c.every(vast_versions, lambda item: item in ['2.0', '3.0'])) if (_c.descend(self, 'vast_event', lambda vast_event: vast_event == 'fullscreen') and _c.sent(self, 'vast_event')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 5) over vast_event, vast_versions', + {'fields': 'vast_event, vast_versions'}, + ) + if not ( + (_c.descend(self, 'vast_versions', lambda vast_versions: _c.every(vast_versions, lambda item: item == '3.0')) if (_c.descend(self, 'vast_event', lambda vast_event: vast_event == 'exitFullscreen') and _c.sent(self, 'vast_event')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 6) over vast_event, vast_versions', + {'fields': 'vast_event, vast_versions'}, + ) + if not ( + (_c.descend(self, 'vast_versions', lambda vast_versions: _c.every(vast_versions, lambda item: item in ['4.0', '4.1', '4.2', '4.3'])) if (_c.descend(self, 'vast_event', lambda vast_event: vast_event in ['playerExpand', 'playerCollapse', 'adExpand', 'adCollapse', 'minimize', 'overlayViewDuration', 'otherAdInteraction']) and _c.sent(self, 'vast_event')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 7) over vast_event, vast_versions', + {'fields': 'vast_event, vast_versions'}, + ) + if not ( + (_c.descend(self, 'vast_versions', lambda vast_versions: _c.every(vast_versions, lambda item: item in ['4.1', '4.2', '4.3'])) if (_c.descend(self, 'vast_event', lambda vast_event: vast_event == 'loaded') and _c.sent(self, 'vast_event')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 8) over vast_event, vast_versions', + {'fields': 'vast_event, vast_versions'}, + ) + if not ( + (_c.descend(self, 'vast_versions', lambda vast_versions: _c.every(vast_versions, lambda item: item in ['4.2', '4.3'])) if (_c.descend(self, 'vast_event', lambda vast_event: vast_event == 'interactiveStart') and _c.sent(self, 'vast_event')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 9) over vast_event, vast_versions', + {'fields': 'vast_event, vast_versions'}, + ) + if not ( + (_c.descend(self, 'vast_versions', lambda vast_versions: _c.every(vast_versions, lambda item: item in ['3.0', '4.1', '4.2', '4.3'])) if (_c.descend(self, 'vast_event', lambda vast_event: vast_event == 'closeLinear') and _c.sent(self, 'vast_event')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'VastTrackerConstraints violates a constraint its schema states outside its properties (core/vast-tracker-constraints.json, root conjunct 10) over vast_event, vast_versions', + {'fields': 'vast_event, vast_versions'}, + ) + return self diff --git a/src/adcp/types/domains/core/warning.py b/src/adcp/types/domains/core/warning.py index a233f4469..770460c8a 100644 --- a/src/adcp/types/domains/core/warning.py +++ b/src/adcp/types/domains/core/warning.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: core/warning.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import warning_code from . import ext as ext_1 from . import warning_resource +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Warning(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -40,3 +46,29 @@ class Warning(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> Warning: + # Compiled from core/warning.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'affected_resource', lambda affected_resource: _c.descend(affected_resource, 'resource_type', lambda resource_type: resource_type == 'package')) if (_c.descend(self, 'code', lambda code: code in ['inventory_shortfall_forecast', 'flight_change_creates_pacing_risk']) and _c.sent(self, 'code')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Warning violates a constraint its schema states outside its properties (core/warning.json, root conjunct 0) over affected_resource, code, resource_type', + {'fields': 'affected_resource, code, resource_type'}, + ) + if not ( + (_c.descend(self, 'affected_resource', lambda affected_resource: _c.descend(affected_resource, 'resource_type', lambda resource_type: resource_type in ['media_buy', 'package'])) if (_c.descend(self, 'code', lambda code: code == 'fields_ignored_due_to_precedence') and _c.sent(self, 'code')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'Warning violates a constraint its schema states outside its properties (core/warning.json, root conjunct 1) over affected_resource, code, resource_type', + {'fields': 'affected_resource, code, resource_type'}, + ) + return self diff --git a/src/adcp/types/domains/core/warning_resource.py b/src/adcp/types/domains/core/warning_resource.py index 11a11e41a..570792f10 100644 --- a/src/adcp/types/domains/core/warning_resource.py +++ b/src/adcp/types/domains/core/warning_resource.py @@ -1,13 +1,19 @@ # generated by datamodel-codegen: # filename: core/warning_resource.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict +from pydantic import ConfigDict, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class ResourceType(StrEnum): @@ -25,3 +31,45 @@ class WarningAffectedResource(AdCPBaseModel): media_buy_id: str | None = None package_id: str | None = None creative_id: str | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> WarningAffectedResource: + # Compiled from core/warning-resource.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'media_buy_id') if _c.descend(self, 'resource_type', lambda resource_type: resource_type == 'media_buy') else True) + ): + raise PydanticCustomError( + 'allOf', + 'WarningAffectedResource violates a constraint its schema states outside its properties (core/warning-resource.json, root conjunct 0) over media_buy_id, resource_type', + {'fields': 'media_buy_id, resource_type'}, + ) + if not ( + ((_c.sent(self, 'media_buy_id') and _c.sent(self, 'package_id')) if _c.descend(self, 'resource_type', lambda resource_type: resource_type == 'package') else True) + ): + raise PydanticCustomError( + 'allOf', + 'WarningAffectedResource violates a constraint its schema states outside its properties (core/warning-resource.json, root conjunct 1) over media_buy_id, package_id, resource_type', + {'fields': 'media_buy_id, package_id, resource_type'}, + ) + if not ( + (_c.sent(self, 'creative_id') if _c.descend(self, 'resource_type', lambda resource_type: resource_type == 'creative') else True) + ): + raise PydanticCustomError( + 'allOf', + 'WarningAffectedResource violates a constraint its schema states outside its properties (core/warning-resource.json, root conjunct 2) over creative_id, resource_type', + {'fields': 'creative_id, resource_type'}, + ) + if not ( + ((_c.sent(self, 'media_buy_id') and _c.sent(self, 'package_id') and _c.sent(self, 'creative_id')) if _c.descend(self, 'resource_type', lambda resource_type: resource_type == 'creative_assignment') else True) + ): + raise PydanticCustomError( + 'allOf', + 'WarningAffectedResource violates a constraint its schema states outside its properties (core/warning-resource.json, root conjunct 3) over creative_id, media_buy_id, package_id, resource_type', + {'fields': 'creative_id, media_buy_id, package_id, resource_type'}, + ) + return self diff --git a/src/adcp/types/domains/core/webhook_challenge_response.py b/src/adcp/types/domains/core/webhook_challenge_response.py index ab3e3427f..985923a89 100644 --- a/src/adcp/types/domains/core/webhook_challenge_response.py +++ b/src/adcp/types/domains/core/webhook_challenge_response.py @@ -1,19 +1,43 @@ # generated by datamodel-codegen: # filename: core/webhook_challenge_response.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar, TypeAlias from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, model_validator +from pydantic import ConfigDict, Field, WrapValidator, model_validator + + +from adcp.types import _conditions as _c from pydantic_core import PydanticCustomError -class WebhookChallengeResponse(AdCPBaseModel): +from adcp.types._branch_dispatch import BranchDispatchMixin + + +class WebhookChallengeResponse(BranchDispatchMixin, AdCPBaseModel): + """What every branch of core/webhook-challenge-response.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``WebhookChallengeResponseChallenge`` + ``WebhookChallengeResponseToken`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + WebhookChallengeResponseBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + model_config = ConfigDict( extra='forbid', ) @@ -35,19 +59,133 @@ class WebhookChallengeResponse(AdCPBaseModel): pattern='^[A-Za-z0-9_.:-]{32,255}$', ), ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'challenge', 'token'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + WebhookChallengeResponseChallenge, + WebhookChallengeResponseToken, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'anyOf' @model_validator(mode='after') def _require_schema_required_group(self) -> WebhookChallengeResponse: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('challenge',), ('token',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'WebhookChallengeResponse requires at least one of these field groups: challenge | token', - ) + # Compiled from core/webhook-challenge-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # challenge + ( + _c.sent(self, 'challenge') + ) + # token + or ( + _c.sent(self, 'token') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'WebhookChallengeResponse requires at least one of these field groups: challenge | token', + ) + if not ( + not (_c.sent(self, 'challenge') and _c.sent(self, 'token')) + ): + raise PydanticCustomError( + 'allOf', + 'WebhookChallengeResponse violates a constraint its schema states outside its properties (core/webhook-challenge-response.json, root conjunct 0) over challenge, token', + {'fields': 'challenge, token'}, + ) + return self + +class WebhookChallengeResponseChallenge(WebhookChallengeResponse): + """The anyOf/0 branch. + + One branch of core/webhook-challenge-response.json, stated as a shape: anyOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + challenge: Annotated[str, Field(description='Echo of the challenge value supplied by the seller.', max_length=255, min_length=32, pattern='^[A-Za-z0-9_.:-]{32,255}$')] + @model_validator(mode='after') + def _require_schema_required_group(self) -> WebhookChallengeResponseChallenge: + # Compiled from core/webhook-challenge-response.json anyOf/0: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + not (_c.sent(self, 'challenge') and _c.sent(self, 'token')) + ): + raise PydanticCustomError( + 'anyOf', + 'WebhookChallengeResponseChallenge violates what core/webhook-challenge-response.json states for its anyOf/0 branch beyond the fields it requires and forbids', + ) + return self + + +class WebhookChallengeResponseToken(WebhookChallengeResponse): + """The anyOf/1 branch. + + One branch of core/webhook-challenge-response.json, stated as a shape: anyOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + token: Annotated[str, Field(description='Backward-compatible alias for `challenge`. Receivers SHOULD prefer `challenge`; sellers MUST accept either field.', max_length=255, min_length=32, pattern='^[A-Za-z0-9_.:-]{32,255}$')] + @model_validator(mode='after') + def _require_schema_required_group(self) -> WebhookChallengeResponseToken: + # Compiled from core/webhook-challenge-response.json anyOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + not (_c.sent(self, 'challenge') and _c.sent(self, 'token')) + ): + raise PydanticCustomError( + 'anyOf', + 'WebhookChallengeResponseToken violates what core/webhook-challenge-response.json states for its anyOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +#: core/webhook-challenge-response.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate WebhookChallengeResponse to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +WebhookChallengeResponseBranches: TypeAlias = Annotated[ + WebhookChallengeResponseChallenge | WebhookChallengeResponseToken, + WrapValidator( + _c.name_the_combinator('anyOf', WebhookChallengeResponse._branch_models(), WebhookChallengeResponse._ROOT_REQUIRED) + ), +] + +# WebhookChallengeResponse is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +WebhookChallengeResponse.model_rebuild(force=True) diff --git a/src/adcp/types/domains/core/wholesale_feed_webhook.py b/src/adcp/types/domains/core/wholesale_feed_webhook.py index 6a78e73dd..c20e1c1fd 100644 --- a/src/adcp/types/domains/core/wholesale_feed_webhook.py +++ b/src/adcp/types/domains/core/wholesale_feed_webhook.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/wholesale_feed_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -9,12 +9,18 @@ from uuid import UUID from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from . import ext as ext_1 from . import wholesale_feed_event +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class NotificationType(StrEnum): product_created = 'product.created' product_updated = 'product.updated' @@ -114,3 +120,109 @@ class WholesaleFeedWebhook(AdCPBaseModel): ), ] ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> WholesaleFeedWebhook: + # Compiled from core/wholesale-feed-webhook.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'product_payload_view') if (_c.descend(self, 'notification_type', lambda notification_type: notification_type in ['product.created', 'product.updated', 'product.priced', 'product.removed']) and _c.sent(self, 'notification_type')) else not (_c.sent(self, 'product_payload_view'))) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 0) over notification_type, product_payload_view', + {'fields': 'notification_type, product_payload_view'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'event_type', lambda event_type: event_type == 'product.created') and _c.sent(event, 'event_type')) if (_c.descend(self, 'notification_type', lambda notification_type: notification_type == 'product.created') and _c.sent(self, 'notification_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 1) over event, event_type, notification_type', + {'fields': 'event, event_type, notification_type'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'event_type', lambda event_type: event_type == 'product.updated') and _c.sent(event, 'event_type')) if (_c.descend(self, 'notification_type', lambda notification_type: notification_type == 'product.updated') and _c.sent(self, 'notification_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 2) over event, event_type, notification_type', + {'fields': 'event, event_type, notification_type'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'event_type', lambda event_type: event_type == 'product.priced') and _c.sent(event, 'event_type')) if (_c.descend(self, 'notification_type', lambda notification_type: notification_type == 'product.priced') and _c.sent(self, 'notification_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 3) over event, event_type, notification_type', + {'fields': 'event, event_type, notification_type'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'event_type', lambda event_type: event_type == 'product.removed') and _c.sent(event, 'event_type')) if (_c.descend(self, 'notification_type', lambda notification_type: notification_type == 'product.removed') and _c.sent(self, 'notification_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 4) over event, event_type, notification_type', + {'fields': 'event, event_type, notification_type'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'event_type', lambda event_type: event_type == 'signal.created') and _c.sent(event, 'event_type')) if (_c.descend(self, 'notification_type', lambda notification_type: notification_type == 'signal.created') and _c.sent(self, 'notification_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 5) over event, event_type, notification_type', + {'fields': 'event, event_type, notification_type'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'event_type', lambda event_type: event_type == 'signal.updated') and _c.sent(event, 'event_type')) if (_c.descend(self, 'notification_type', lambda notification_type: notification_type == 'signal.updated') and _c.sent(self, 'notification_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 6) over event, event_type, notification_type', + {'fields': 'event, event_type, notification_type'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'event_type', lambda event_type: event_type == 'signal.priced') and _c.sent(event, 'event_type')) if (_c.descend(self, 'notification_type', lambda notification_type: notification_type == 'signal.priced') and _c.sent(self, 'notification_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 7) over event, event_type, notification_type', + {'fields': 'event, event_type, notification_type'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'event_type', lambda event_type: event_type == 'signal.removed') and _c.sent(event, 'event_type')) if (_c.descend(self, 'notification_type', lambda notification_type: notification_type == 'signal.removed') and _c.sent(self, 'notification_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 8) over event, event_type, notification_type', + {'fields': 'event, event_type, notification_type'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'event_type', lambda event_type: event_type == 'wholesale_feed.bulk_change') and _c.sent(event, 'event_type')) if (_c.descend(self, 'notification_type', lambda notification_type: notification_type == 'wholesale_feed.bulk_change') and _c.sent(self, 'notification_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 9) over event, event_type, notification_type', + {'fields': 'event, event_type, notification_type'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'payload', lambda payload: _c.descend(payload, 'applies_to', lambda applies_to: _c.descend(applies_to, 'scope', lambda scope: scope == 'public') and _c.supplied(applies_to, 'scope')) and _c.supplied(payload, 'applies_to')) and _c.sent(event, 'payload')) if (_c.descend(self, 'cache_scope', lambda cache_scope: cache_scope == 'public') and _c.sent(self, 'cache_scope')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 10) over applies_to, cache_scope, event, payload, scope', + {'fields': 'applies_to, cache_scope, event, payload, scope'}, + ) + if not ( + (_c.descend(self, 'event', lambda event: _c.descend(event, 'payload', lambda payload: _c.descend(payload, 'applies_to', lambda applies_to: _c.descend(applies_to, 'scope', lambda scope: scope == 'account') and _c.supplied(applies_to, 'scope')) and _c.supplied(payload, 'applies_to')) and _c.sent(event, 'payload')) if (_c.descend(self, 'cache_scope', lambda cache_scope: cache_scope == 'account') and _c.sent(self, 'cache_scope')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'WholesaleFeedWebhook violates a constraint its schema states outside its properties (core/wholesale-feed-webhook.json, root conjunct 11) over applies_to, cache_scope, event, payload, scope', + {'fields': 'applies_to, cache_scope, event, payload, scope'}, + ) + return self diff --git a/src/adcp/types/domains/creative/__init__.py b/src/adcp/types/domains/creative/__init__.py index e250f48bc..13eb67715 100644 --- a/src/adcp/types/domains/creative/__init__.py +++ b/src/adcp/types/domains/creative/__init__.py @@ -9,155 +9,340 @@ it from its own schema's module, ``adcp.types.domains.creative.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.creative.audit_observation import ( - ClaimedValue, - CreativeAuditObservation, - Details, - HumanOversight, -) -from adcp.types.domains.creative.creative_assignment_changed_webhook import ( - ChangeKind, - CreativeAssignmentChangedWebhook, -) -from adcp.types.domains.creative.creative_feature_result import CreativeFeatureResult -from adcp.types.domains.creative.creative_purged_webhook import CreativePurgedWebhook, PurgeKind -from adcp.types.domains.creative.creative_status_changed_webhook import ( - CreativeStatusChangedWebhook, - From, - Transition, -) -from adcp.types.domains.creative.get_creative_delivery_request import GetCreativeDeliveryRequest -from adcp.types.domains.creative.get_creative_delivery_response import ( - GetCreativeDeliveryResponse, - Pagination, - ReportingPeriod, -) -from adcp.types.domains.creative.get_creative_features_async_response_submitted import ( - GetCreativeFeaturesSubmitted, -) -from adcp.types.domains.creative.get_creative_features_request import GetCreativeFeaturesRequest -from adcp.types.domains.creative.get_creative_features_response import ( - GetCreativeFeaturesResponse, - GetCreativeFeaturesResponse1, - GetCreativeFeaturesResponse2, - GetCreativeFeaturesResponse3, -) -from adcp.types.domains.creative.get_creative_features_terminal_success import ( - GetCreativeFeaturesSuccess, -) -from adcp.types.domains.creative.list_creative_formats_request import ( - ListCreativeFormatsRequestCreativeAgent, - Type, -) -from adcp.types.domains.creative.list_creative_formats_response import ( - CreativeAgent, - ListCreativeFormatsResponseCreativeAgent, -) -from adcp.types.domains.creative.list_creatives_request import ( - AssignmentProjection, - Field1, - ListCreativesRequest, - Sort, -) -from adcp.types.domains.creative.list_creatives_response import ( - Assets, - AssignedPackage, - Assignments, - Creatives, - Creatives1, - Indicator, - IndicatorTypesEvaluatedEnum, - ListCreativesResponse, - LocalizationUnavailable, - Purge, - QuerySummary, - Snapshot, - SortApplied, - StatusSummary, -) -from adcp.types.domains.creative.list_transformers_request import ( - ExpandPaginationItem, - ListTransformersRequestCreativeAgent, - OutputCapabilityId, -) -from adcp.types.domains.creative.list_transformers_response import ( - ListTransformersResponseCreativeAgent, -) -from adcp.types.domains.creative.preview_creative_request import ( - Input10, - PreviewCreativeRequest, - Request, - RequestType, -) -from adcp.types.domains.creative.preview_creative_response import ( - Input2, - Preview, - Preview2, - Preview3, - PreviewCreativeResponse, - PreviewCreativeResponse1, - PreviewCreativeResponse2, - PreviewCreativeResponse3, - PreviewCreativeResponse4, - Response, - Result, -) -from adcp.types.domains.creative.preview_render import ( - Dimensions, - Embedding, - PreviewRender, - PreviewRender1, - PreviewRender2, - PreviewRender3, -) -from adcp.types.domains.creative.sync_creatives_async_response_input_required import ( - Reason, - SyncCreativesInputRequired, -) -from adcp.types.domains.creative.sync_creatives_async_response_submitted import ( - SyncCreativesSubmitted, -) -from adcp.types.domains.creative.sync_creatives_async_response_working import SyncCreativesWorking -from adcp.types.domains.creative.sync_creatives_request import ( - Assignment, - AssignmentOperations, - AssignmentOperations1, - AssignmentOperations2, - AssignmentOperations3, - PlacementId, - SyncCreativesRequest, -) -from adcp.types.domains.creative.sync_creatives_response import ( - SyncCreativesResponse, - SyncCreativesResponse1, - SyncCreativesResponse2, - SyncCreativesResponse3, -) -from adcp.types.domains.creative.validate_input_request import ( - Targets, - Targets1, - Targets2, - Targets3, - Targets4, - ValidateInputRequest, -) -from adcp.types.domains.creative.validate_input_response import ValidateInputResponse -from adcp.types.domains.creative.validate_input_result import ( - Kind, - ResultKind, - Target, - ValidateInputResult, - Violation, - Warning, -) -from adcp.types.domains.creative.video_brief import Segment, VideoBrief +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.creative.audit_observation import ( + ClaimedValue, + CreativeAuditObservation, + Details, + HumanOversight, + ) + from adcp.types.domains.creative.creative_assignment_changed_webhook import ( + ChangeKind, + CreativeAssignmentChangedWebhook, + ) + from adcp.types.domains.creative.creative_feature_result import CreativeFeatureResult + from adcp.types.domains.creative.creative_purged_webhook import CreativePurgedWebhook, PurgeKind + from adcp.types.domains.creative.creative_status_changed_webhook import ( + CreativeStatusChangedWebhook, + From, + Transition, + ) + from adcp.types.domains.creative.get_creative_delivery_request import GetCreativeDeliveryRequest + from adcp.types.domains.creative.get_creative_delivery_response import ( + GetCreativeDeliveryResponse, + Pagination, + ReportingPeriod, + ) + from adcp.types.domains.creative.get_creative_features_async_response_submitted import ( + GetCreativeFeaturesSubmitted, + ) + from adcp.types.domains.creative.get_creative_features_request import GetCreativeFeaturesRequest + from adcp.types.domains.creative.get_creative_features_response import ( + GetCreativeFeaturesResponse, + GetCreativeFeaturesResponse1, + GetCreativeFeaturesResponse2, + GetCreativeFeaturesResponse3, + ) + from adcp.types.domains.creative.get_creative_features_terminal_success import ( + GetCreativeFeaturesSuccess, + ) + from adcp.types.domains.creative.list_creative_formats_request import ( + ListCreativeFormatsRequestCreativeAgent, + Type, + ) + from adcp.types.domains.creative.list_creative_formats_response import ( + CreativeAgent, + ListCreativeFormatsResponseCreativeAgent, + ) + from adcp.types.domains.creative.list_creatives_request import ( + AssignmentProjection, + Field1, + ListCreativesRequest, + Sort, + ) + from adcp.types.domains.creative.list_creatives_response import ( + Assets, + AssignedPackage, + Assignments, + Creatives, + Creatives1, + Indicator, + IndicatorTypesEvaluatedEnum, + ListCreativesResponse, + LocalizationUnavailable, + Purge, + QuerySummary, + Snapshot, + SortApplied, + StatusSummary, + ) + from adcp.types.domains.creative.list_transformers_request import ( + ExpandPaginationItem, + ListTransformersRequestCreativeAgent, + OutputCapabilityId, + ) + from adcp.types.domains.creative.list_transformers_response import ( + ListTransformersResponseCreativeAgent, + ) + from adcp.types.domains.creative.preview_creative_request import ( + Input10, + PreviewCreativeRequest, + Request, + RequestType, + ) + from adcp.types.domains.creative.preview_creative_response import ( + Input2, + Preview, + Preview2, + Preview3, + PreviewCreativeResponse, + PreviewCreativeResponse1, + PreviewCreativeResponse2, + PreviewCreativeResponse3, + PreviewCreativeResponse4, + Response, + Result, + ) + from adcp.types.domains.creative.preview_render import ( + Dimensions, + Embedding, + PreviewRender, + PreviewRender1, + PreviewRender2, + PreviewRender3, + ) + from adcp.types.domains.creative.sync_creatives_async_response_input_required import ( + Reason, + SyncCreativesInputRequired, + ) + from adcp.types.domains.creative.sync_creatives_async_response_submitted import ( + SyncCreativesSubmitted, + ) + from adcp.types.domains.creative.sync_creatives_async_response_working import ( + SyncCreativesWorking, + ) + from adcp.types.domains.creative.sync_creatives_request import ( + Assignment, + AssignmentOperations, + AssignmentOperations1, + AssignmentOperations2, + AssignmentOperations3, + PlacementId, + SyncCreativesRequest, + ) + from adcp.types.domains.creative.sync_creatives_response import ( + SyncCreativesResponse, + SyncCreativesResponse1, + SyncCreativesResponse2, + SyncCreativesResponse3, + ) + from adcp.types.domains.creative.validate_input_request import ( + Targets, + Targets1, + Targets2, + Targets3, + Targets4, + ValidateInputRequest, + ) + from adcp.types.domains.creative.validate_input_response import ValidateInputResponse + from adcp.types.domains.creative.validate_input_result import ( + Kind, + ResultKind, + Target, + ValidateInputResult, + Violation, + Warning, + ) + from adcp.types.domains.creative.video_brief import Segment, VideoBrief + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "creative.audit_observation": ( + "ClaimedValue", + "CreativeAuditObservation", + "Details", + "HumanOversight", + ), + "creative.creative_assignment_changed_webhook": ( + "ChangeKind", + "CreativeAssignmentChangedWebhook", + ), + "creative.creative_feature_result": ("CreativeFeatureResult",), + "creative.creative_purged_webhook": ( + "CreativePurgedWebhook", + "PurgeKind", + ), + "creative.creative_status_changed_webhook": ( + "CreativeStatusChangedWebhook", + "From", + "Transition", + ), + "creative.get_creative_delivery_request": ("GetCreativeDeliveryRequest",), + "creative.get_creative_delivery_response": ( + "GetCreativeDeliveryResponse", + "Pagination", + "ReportingPeriod", + ), + "creative.get_creative_features_async_response_submitted": ("GetCreativeFeaturesSubmitted",), + "creative.get_creative_features_request": ("GetCreativeFeaturesRequest",), + "creative.get_creative_features_response": ( + "GetCreativeFeaturesResponse", + "GetCreativeFeaturesResponse1", + "GetCreativeFeaturesResponse2", + "GetCreativeFeaturesResponse3", + ), + "creative.get_creative_features_terminal_success": ("GetCreativeFeaturesSuccess",), + "creative.list_creative_formats_request": ( + "ListCreativeFormatsRequestCreativeAgent", + "Type", + ), + "creative.list_creative_formats_response": ( + "CreativeAgent", + "ListCreativeFormatsResponseCreativeAgent", + ), + "creative.list_creatives_request": ( + "AssignmentProjection", + "Field1", + "ListCreativesRequest", + "Sort", + ), + "creative.list_creatives_response": ( + "Assets", + "AssignedPackage", + "Assignments", + "Creatives", + "Creatives1", + "Indicator", + "IndicatorTypesEvaluatedEnum", + "ListCreativesResponse", + "LocalizationUnavailable", + "Purge", + "QuerySummary", + "Snapshot", + "SortApplied", + "StatusSummary", + ), + "creative.list_transformers_request": ( + "ExpandPaginationItem", + "ListTransformersRequestCreativeAgent", + "OutputCapabilityId", + ), + "creative.list_transformers_response": ("ListTransformersResponseCreativeAgent",), + "creative.preview_creative_request": ( + "Input10", + "PreviewCreativeRequest", + "Request", + "RequestType", + ), + "creative.preview_creative_response": ( + "Input2", + "Preview", + "Preview2", + "Preview3", + "PreviewCreativeResponse", + "PreviewCreativeResponse1", + "PreviewCreativeResponse2", + "PreviewCreativeResponse3", + "PreviewCreativeResponse4", + "Response", + "Result", + ), + "creative.preview_render": ( + "Dimensions", + "Embedding", + "PreviewRender", + "PreviewRender1", + "PreviewRender2", + "PreviewRender3", + ), + "creative.sync_creatives_async_response_input_required": ( + "Reason", + "SyncCreativesInputRequired", + ), + "creative.sync_creatives_async_response_submitted": ("SyncCreativesSubmitted",), + "creative.sync_creatives_async_response_working": ("SyncCreativesWorking",), + "creative.sync_creatives_request": ( + "Assignment", + "AssignmentOperations", + "AssignmentOperations1", + "AssignmentOperations2", + "AssignmentOperations3", + "PlacementId", + "SyncCreativesRequest", + ), + "creative.sync_creatives_response": ( + "SyncCreativesResponse", + "SyncCreativesResponse1", + "SyncCreativesResponse2", + "SyncCreativesResponse3", + ), + "creative.validate_input_request": ( + "Targets", + "Targets1", + "Targets2", + "Targets3", + "Targets4", + "ValidateInputRequest", + ), + "creative.validate_input_response": ("ValidateInputResponse",), + "creative.validate_input_result": ( + "Kind", + "ResultKind", + "Target", + "ValidateInputResult", + "Violation", + "Warning", + ), + "creative.video_brief": ( + "Segment", + "VideoBrief", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/creative/get_creative_delivery_request.py b/src/adcp/types/domains/creative/get_creative_delivery_request.py index a0d8958db..63b69e187 100644 --- a/src/adcp/types/domains/creative/get_creative_delivery_request.py +++ b/src/adcp/types/domains/creative/get_creative_delivery_request.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: creative/get_creative_delivery_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest, SchemaInt -from typing import Annotated +from typing import Annotated, ClassVar from pydantic import ConfigDict, Field, model_validator @@ -17,6 +17,9 @@ from ..core.version_envelope import AdcpVersionEnvelope +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -73,19 +76,29 @@ class GetCreativeDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'creative_ids', 'media_buy_ids'}) @model_validator(mode='after') def _require_schema_required_group(self) -> GetCreativeDeliveryRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('media_buy_ids',), ('creative_ids',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'GetCreativeDeliveryRequest requires at least one of these field groups: media_buy_ids | creative_ids', - ) + # Compiled from creative/get-creative-delivery-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # media_buy_ids + ( + _c.sent(self, 'media_buy_ids') + ) + # creative_ids + or ( + _c.sent(self, 'creative_ids') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'GetCreativeDeliveryRequest requires at least one of these field groups: media_buy_ids | creative_ids', + ) + return self diff --git a/src/adcp/types/domains/creative/get_creative_delivery_response.py b/src/adcp/types/domains/creative/get_creative_delivery_response.py index e710949a3..03391ac9d 100644 --- a/src/adcp/types/domains/creative/get_creative_delivery_response.py +++ b/src/adcp/types/domains/creative/get_creative_delivery_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/get_creative_delivery_response.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -99,8 +99,12 @@ class Creative(AdCPBaseModel): ge=0, ), ] = None + # A response carries the tolerant read-back node, not the strict input class: a seller may + # send an unknown field, or a manifest instance of a sibling class, and neither may become + # acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in + # scripts/post_generate_fixes.py. variants: Annotated[ - list[creative_variant.CreativeVariant], + list[creative_variant._DeliveryVariant], Field( description='Variant-level delivery breakdown. Each agent-unique variant_id identifies one immutable served execution and each row includes metrics from exactly one source revision and, for localized delivery, exactly one locale variant. A distinct revision, locale, or rendered manifest receives a distinct variant_id; metrics MUST NOT cross those boundaries. For standard creatives, contains one row per source revision and locale represented in the reporting period. For asset group optimization, one per combination, source revision, and locale. For generative creative, one per generated execution, source revision, and locale. Empty when a creative has no variants yet.' ), diff --git a/src/adcp/types/domains/creative/get_creative_features_async_response_submitted.py b/src/adcp/types/domains/creative/get_creative_features_async_response_submitted.py index e941d061b..e2d121839 100644 --- a/src/adcp/types/domains/creative/get_creative_features_async_response_submitted.py +++ b/src/adcp/types/domains/creative/get_creative_features_async_response_submitted.py @@ -1,18 +1,24 @@ # generated by datamodel-codegen: # filename: creative/get_creative_features_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class GetCreativeFeaturesSubmitted(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -46,3 +52,21 @@ class GetCreativeFeaturesSubmitted(AdCPBaseModel): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetCreativeFeaturesSubmitted: + # Compiled from creative/get-creative-features-async-response-submitted.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'results') or _c.supplied(self, 'errors') or _c.supplied(self, 'detail_url') or _c.supplied(self, 'pricing_option_id') or _c.supplied(self, 'vendor_cost') or _c.supplied(self, 'currency') or _c.supplied(self, 'consumption')) + ): + raise PydanticCustomError( + 'allOf', + 'GetCreativeFeaturesSubmitted violates a constraint its schema states outside its properties (creative/get-creative-features-async-response-submitted.json, root conjunct 0) over consumption, currency, detail_url, errors, pricing_option_id, results, vendor_cost', + {'fields': 'consumption, currency, detail_url, errors, pricing_option_id, results, vendor_cost'}, + ) + return self diff --git a/src/adcp/types/domains/creative/get_creative_features_terminal_success.py b/src/adcp/types/domains/creative/get_creative_features_terminal_success.py index a834279b9..a471029e1 100644 --- a/src/adcp/types/domains/creative/get_creative_features_terminal_success.py +++ b/src/adcp/types/domains/creative/get_creative_features_terminal_success.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/get_creative_features_terminal_success.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from pydantic import AnyUrl, ConfigDict, Field, StrictFloat, model_validator from ..core import context as context_1 from ..core import creative_consumption @@ -15,6 +15,12 @@ from . import audit_observation, creative_feature_result +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class GetCreativeFeaturesSuccess(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -64,3 +70,21 @@ class GetCreativeFeaturesSuccess(AdCPBaseModel): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetCreativeFeaturesSuccess: + # Compiled from creative/get-creative-features-terminal-success.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'errors') or (_c.descend(self, 'status', lambda status: status == 'submitted') and _c.supplied(self, 'status'))) + ): + raise PydanticCustomError( + 'allOf', + 'GetCreativeFeaturesSuccess violates a constraint its schema states outside its properties (creative/get-creative-features-terminal-success.json, root conjunct 0) over errors, status', + {'fields': 'errors, status'}, + ) + return self diff --git a/src/adcp/types/domains/creative/list_creative_formats_request.py b/src/adcp/types/domains/creative/list_creative_formats_request.py index cc7d1aaac..778374c55 100644 --- a/src/adcp/types/domains/creative/list_creative_formats_request.py +++ b/src/adcp/types/domains/creative/list_creative_formats_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_creative_formats_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import account_ref from ..core import context as context_1 @@ -22,6 +22,18 @@ from ..enums import wcag_level as wcag_level_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + +from pydantic import WrapValidator + + +from adcp.types.coercion import coerce_to_unique_enum_list + + class Type(StrEnum): audio = 'audio' video = 'video' @@ -93,15 +105,29 @@ class ListCreativeFormatsRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description='Filter to formats that meet at least this WCAG conformance level (A < AA < AAA)' ), ] = None + # Order-preserving de-duplication of a repeated filter value. This is the one coercion that + # pydantic does not already do natively: an enum list accepts strings because the generated + # enums are StrEnum, and a model field accepts a dict because pydantic does that, but nothing + # drops a repeat. It was applied by a patch in _ergonomic.py; see salesagent-1jj5e. disclosure_positions: Annotated[ - list[disclosure_position.DisclosurePosition] | None, + Annotated[ + list[disclosure_position.DisclosurePosition] | None, + WrapValidator(coerce_to_unique_enum_list(disclosure_position.DisclosurePosition)), + ], Field( description="Filter to formats that support all of these disclosure positions. When a format has disclosure_capabilities, match against those positions. Otherwise fall back to supported_disclosure_positions. Use to find formats compatible with a brief's compliance requirements.", min_length=1, ), ] = None + # Order-preserving de-duplication of a repeated filter value. This is the one coercion that + # pydantic does not already do natively: an enum list accepts strings because the generated + # enums are StrEnum, and a model field accepts a dict because pydantic does that, but nothing + # drops a repeat. It was applied by a patch in _ergonomic.py; see salesagent-1jj5e. disclosure_persistence: Annotated[ - list[disclosure_persistence_1.DisclosurePersistence] | None, + Annotated[ + list[disclosure_persistence_1.DisclosurePersistence] | None, + WrapValidator(coerce_to_unique_enum_list(disclosure_persistence_1.DisclosurePersistence)), + ], Field( description='Filter to formats where each requested persistence mode is supported by at least one position in disclosure_capabilities. Different positions may satisfy different modes. Use to find formats compatible with jurisdiction-specific persistence requirements (e.g., continuous for EU AI Act).', min_length=1, @@ -140,3 +166,21 @@ class ListCreativeFormatsRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): pagination: pagination_request.PaginationRequest | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ListCreativeFormatsRequestCreativeAgent: + # Compiled from creative/list-creative-formats-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'account') if (_c.descend(self, 'include_pricing', lambda include_pricing: include_pricing == True) and _c.sent(self, 'include_pricing')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ListCreativeFormatsRequestCreativeAgent violates a constraint its schema states outside its properties (creative/list-creative-formats-request.json, root conjunct 0) over account, include_pricing', + {'fields': 'account, include_pricing'}, + ) + return self diff --git a/src/adcp/types/domains/creative/list_creatives_request.py b/src/adcp/types/domains/creative/list_creatives_request.py index 7d945c87d..130e3fe47 100644 --- a/src/adcp/types/domains/creative/list_creatives_request.py +++ b/src/adcp/types/domains/creative/list_creatives_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_creatives_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import account_ref from ..core import context as context_1 @@ -21,6 +21,12 @@ from ..enums import creative_sort_field, sort_direction +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class AssignmentProjection(StrEnum): all = 'all' matching = 'matching' @@ -142,3 +148,29 @@ class ListCreativesRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ListCreativesRequest: + # Compiled from creative/list-creatives-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'account') if (_c.descend(self, 'include_pricing', lambda include_pricing: include_pricing == True) and _c.sent(self, 'include_pricing')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ListCreativesRequest violates a constraint its schema states outside its properties (creative/list-creatives-request.json, root conjunct 0) over account, include_pricing', + {'fields': 'account, include_pricing'}, + ) + if not ( + ((_c.descend(self, 'filters', lambda filters: _c.sent(filters, 'indicator_types')) and _c.sent(self, 'filters')) if (_c.descend(self, 'assignment_projection', lambda assignment_projection: assignment_projection == 'matching') and _c.sent(self, 'assignment_projection')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ListCreativesRequest violates a constraint its schema states outside its properties (creative/list-creatives-request.json, root conjunct 1) over assignment_projection, filters, indicator_types', + {'fields': 'assignment_projection, filters, indicator_types'}, + ) + return self diff --git a/src/adcp/types/domains/creative/list_transformers_request.py b/src/adcp/types/domains/creative/list_transformers_request.py index ad24fcc9e..3825fd954 100644 --- a/src/adcp/types/domains/creative/list_transformers_request.py +++ b/src/adcp/types/domains/creative/list_transformers_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_transformers_request.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -9,8 +9,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import account_ref from ..core import context as context_1 @@ -19,6 +18,13 @@ from ..core.version_envelope import AdcpVersionEnvelope +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class OutputCapabilityId(ScalarStr): __slots__ = () _constraints = {'pattern': '^[a-zA-Z0-9_-]+$'} @@ -112,3 +118,21 @@ class ListTransformersRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): pagination: pagination_request.PaginationRequest | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ListTransformersRequestCreativeAgent: + # Compiled from creative/list-transformers-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'account') if (_c.descend(self, 'include_pricing', lambda include_pricing: include_pricing == True) and _c.sent(self, 'include_pricing')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ListTransformersRequestCreativeAgent violates a constraint its schema states outside its properties (creative/list-transformers-request.json, root conjunct 0) over account, include_pricing', + {'fields': 'account, include_pricing'}, + ) + return self diff --git a/src/adcp/types/domains/creative/preview_creative_request.py b/src/adcp/types/domains/creative/preview_creative_request.py index 8c1daca29..d98bbc199 100644 --- a/src/adcp/types/domains/creative/preview_creative_request.py +++ b/src/adcp/types/domains/creative/preview_creative_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/preview_creative_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import context as context_1 from ..core import creative_manifest as creative_manifest_1 @@ -21,6 +21,12 @@ from ..enums import creative_quality, preview_output_format +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class RequestType(StrEnum): single = 'single' batch = 'batch' @@ -210,3 +216,61 @@ class PreviewCreativeRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> PreviewCreativeRequest: + # Compiled from creative/preview-creative-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'creative_manifest') or _c.sent(self, 'creative_id')) if _c.descend(self, 'request_type', lambda request_type: request_type == 'single') else True) + ): + raise PydanticCustomError( + 'allOf', + 'PreviewCreativeRequest violates a constraint its schema states outside its properties (creative/preview-creative-request.json, root conjunct 0) over creative_id, creative_manifest, request_type', + {'fields': 'creative_id, creative_manifest, request_type'}, + ) + if not ( + (_c.sent(self, 'requests') if _c.descend(self, 'request_type', lambda request_type: request_type == 'batch') else True) + ): + raise PydanticCustomError( + 'allOf', + 'PreviewCreativeRequest violates a constraint its schema states outside its properties (creative/preview-creative-request.json, root conjunct 1) over request_type, requests', + {'fields': 'request_type, requests'}, + ) + if not ( + (_c.sent(self, 'variant_id') if _c.descend(self, 'request_type', lambda request_type: request_type == 'variant') else True) + ): + raise PydanticCustomError( + 'allOf', + 'PreviewCreativeRequest violates a constraint its schema states outside its properties (creative/preview-creative-request.json, root conjunct 2) over request_type, variant_id', + {'fields': 'request_type, variant_id'}, + ) + if not ( + not (_c.sent(self, 'creative_manifest') and _c.sent(self, 'creative_id')) + ): + raise PydanticCustomError( + 'allOf', + 'PreviewCreativeRequest violates a constraint its schema states outside its properties (creative/preview-creative-request.json, root conjunct 3) over creative_id, creative_manifest', + {'fields': 'creative_id, creative_manifest'}, + ) + if not ( + not (_c.sent(self, 'target_capability_id') and _c.sent(self, 'format_id')) + ): + raise PydanticCustomError( + 'allOf', + 'PreviewCreativeRequest violates a constraint its schema states outside its properties (creative/preview-creative-request.json, root conjunct 4) over format_id, target_capability_id', + {'fields': 'format_id, target_capability_id'}, + ) + if not ( + not (((_c.sent(self, 'target_capability_id') and _c.sent(self, 'requests')) and _c.descend(self, 'requests', lambda requests: _c.some(requests, lambda item: _c.supplied(item, 'format_id')))) or ((_c.sent(self, 'format_id') and _c.sent(self, 'requests')) and _c.descend(self, 'requests', lambda requests: _c.some(requests, lambda item: _c.supplied(item, 'target_capability_id')))) or (_c.sent(self, 'requests') and _c.descend(self, 'requests', lambda requests: _c.some(requests, lambda item: _c.supplied(item, 'target_capability_id'))) and _c.descend(self, 'requests', lambda requests: _c.some(requests, lambda item: _c.supplied(item, 'format_id'))))) + ): + raise PydanticCustomError( + 'allOf', + 'PreviewCreativeRequest violates a constraint its schema states outside its properties (creative/preview-creative-request.json, root conjunct 5) over format_id, requests, target_capability_id', + {'fields': 'format_id, requests, target_capability_id'}, + ) + return self diff --git a/src/adcp/types/domains/creative/preview_creative_response.py b/src/adcp/types/domains/creative/preview_creative_response.py index 82d3d678d..d60adea45 100644 --- a/src/adcp/types/domains/creative/preview_creative_response.py +++ b/src/adcp/types/domains/creative/preview_creative_response.py @@ -96,7 +96,11 @@ class PreviewCreativeResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelo variant_id: str creative_id: str | None = None previews: Annotated[list[Preview3], Field(min_length=1)] - manifest: creative_manifest_1.CreativeManifest | None = None + # A response carries the tolerant read-back node, not the strict input class: a seller may + # send an unknown field, or a manifest instance of a sibling class, and neither may become + # acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in + # scripts/post_generate_fixes.py. + manifest: creative_manifest_1._ReadbackCreativeManifest | None = None expires_at: AwareDatetime | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None diff --git a/src/adcp/types/domains/creative/sync_creatives_request.py b/src/adcp/types/domains/creative/sync_creatives_request.py index 11ff22d25..bbd1bbf69 100644 --- a/src/adcp/types/domains/creative/sync_creatives_request.py +++ b/src/adcp/types/domains/creative/sync_creatives_request.py @@ -1,15 +1,14 @@ # generated by datamodel-codegen: # filename: creative/sync_creatives_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest -from typing import Annotated, Literal +from typing import Annotated, ClassVar, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import account_ref @@ -22,6 +21,10 @@ from ..enums import validation_mode as validation_mode_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + from pydantic_core import PydanticCustomError @@ -182,19 +185,57 @@ class SyncCreativesRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'assignment_operations', 'assignments', 'creatives'}) @model_validator(mode='after') def _require_schema_required_group(self) -> SyncCreativesRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('creatives',), ('assignments',), ('assignment_operations',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'SyncCreativesRequest requires at least one of these field groups: creatives | assignments | assignment_operations', - ) + # Compiled from creative/sync-creatives-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # creatives + ( + _c.sent(self, 'creatives') + ) + # assignments + or ( + _c.sent(self, 'assignments') + ) + # assignment_operations + or ( + _c.sent(self, 'assignment_operations') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'SyncCreativesRequest requires at least one of these field groups: creatives | assignments | assignment_operations', + ) + if not ( + (_c.sent(self, 'creatives') if (_c.descend(self, 'delete_missing', lambda delete_missing: delete_missing == True) and _c.sent(self, 'delete_missing')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SyncCreativesRequest violates a constraint its schema states outside its properties (creative/sync-creatives-request.json, root conjunct 0) over creatives, delete_missing', + {'fields': 'creatives, delete_missing'}, + ) + if not ( + not (_c.sent(self, 'assignments') and _c.sent(self, 'assignment_operations')) + ): + raise PydanticCustomError( + 'allOf', + 'SyncCreativesRequest violates a constraint its schema states outside its properties (creative/sync-creatives-request.json, root conjunct 1) over assignment_operations, assignments', + {'fields': 'assignment_operations, assignments'}, + ) + if not ( + (_c.descend(self, 'validation_mode', lambda validation_mode: validation_mode == 'strict' and _c.json_type(validation_mode, 'string')) if _c.sent(self, 'assignment_operations') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SyncCreativesRequest violates a constraint its schema states outside its properties (creative/sync-creatives-request.json, root conjunct 2) over assignment_operations, validation_mode', + {'fields': 'assignment_operations, validation_mode'}, + ) + return self diff --git a/src/adcp/types/domains/creative/validate_input_result.py b/src/adcp/types/domains/creative/validate_input_result.py index c7b4fbbf7..6c7aae772 100644 --- a/src/adcp/types/domains/creative/validate_input_result.py +++ b/src/adcp/types/domains/creative/validate_input_result.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/validate_input_result.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import macro_resolution_result +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Kind(StrEnum): canonical = 'canonical' product = 'product' @@ -108,3 +114,29 @@ class ValidateInputResult(AdCPBaseModel): description='Per-token compatibility against the selected target. Required or unsafe `unsupported`/`ambiguous` results make the target `validated_fail`; deliberate preservation for the declared downstream resolver may still pass.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ValidateInputResult: + # Compiled from creative/validate-input-result.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (not (_c.sent(self, 'violations')) if (_c.descend(self, 'result_kind', lambda result_kind: result_kind == 'validated_pass') and _c.sent(self, 'result_kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ValidateInputResult violates a constraint its schema states outside its properties (creative/validate-input-result.json, root conjunct 0) over result_kind, violations', + {'fields': 'result_kind, violations'}, + ) + if not ( + (not (_c.sent(self, 'violations')) if (_c.descend(self, 'result_kind', lambda result_kind: result_kind == 'unvalidatable_nondeterministic') and _c.sent(self, 'result_kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ValidateInputResult violates a constraint its schema states outside its properties (creative/validate-input-result.json, root conjunct 1) over result_kind, violations', + {'fields': 'result_kind, violations'}, + ) + return self diff --git a/src/adcp/types/domains/enums/__init__.py b/src/adcp/types/domains/enums/__init__.py index b5693f91b..08c6d953a 100644 --- a/src/adcp/types/domains/enums/__init__.py +++ b/src/adcp/types/domains/enums/__init__.py @@ -9,225 +9,480 @@ it from its own schema's module, ``adcp.types.domains.enums.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.enums.account_currency_mode import AccountCurrencyMode -from adcp.types.domains.enums.account_scope import AccountScope -from adcp.types.domains.enums.account_status import AccountStatus -from adcp.types.domains.enums.action_not_allowed_reason import ActionNotAllowedReason -from adcp.types.domains.enums.action_source import ActionSource -from adcp.types.domains.enums.activation_method import CreativeActivationMethod -from adcp.types.domains.enums.adcp_protocol import AdcpProtocol -from adcp.types.domains.enums.adjustment_kind import PriceAdjustmentKind -from adcp.types.domains.enums.advertiser_industry import AdvertiserIndustry -from adcp.types.domains.enums.age_determination_basis import AgeDeterminationBasis -from adcp.types.domains.enums.age_verification_method import AgeVerificationMethod -from adcp.types.domains.enums.assessment_status import AssessmentStatus -from adcp.types.domains.enums.asset_content_type import AssetContentType -from adcp.types.domains.enums.attestation_claim import AttestationClaim -from adcp.types.domains.enums.attribution_methodology import AttributionMethodology -from adcp.types.domains.enums.attribution_model import AttributionModel -from adcp.types.domains.enums.audience_evidence_methodology import AudienceEvidenceMethodology -from adcp.types.domains.enums.audience_resolution_method import AudienceResolutionMethod -from adcp.types.domains.enums.audience_source import AudienceSource -from adcp.types.domains.enums.audience_status import AudienceStatus -from adcp.types.domains.enums.audience_subject_type import AudienceSubjectType -from adcp.types.domains.enums.audio_channel_layout import AudioChannelLayout -from adcp.types.domains.enums.audio_distribution_type import AudioDistributionType -from adcp.types.domains.enums.auth_scheme import AuthenticationScheme -from adcp.types.domains.enums.availability_status import AvailabilityStatus -from adcp.types.domains.enums.available_metric import AvailableMetric -from adcp.types.domains.enums.billing_party import BillingParty -from adcp.types.domains.enums.binary_verdict import BinaryVerdict -from adcp.types.domains.enums.brand_agent_type import BrandAgentType -from adcp.types.domains.enums.browser_family import BrowserFamily -from adcp.types.domains.enums.c2pa_watermark_action import C2PaWatermarkAction -from adcp.types.domains.enums.canceled_by import CanceledBy -from adcp.types.domains.enums.canonical_media_buy_action import CanonicalMediaBuyActionName -from adcp.types.domains.enums.canonical_media_buy_action_mode import CanonicalMediaBuyActionMode -from adcp.types.domains.enums.catalog_action import CatalogAction -from adcp.types.domains.enums.catalog_item_status import CatalogItemStatus -from adcp.types.domains.enums.catalog_type import CatalogType -from adcp.types.domains.enums.channels import MediaChannel -from adcp.types.domains.enums.cloud_storage_protocol import CloudStorageProtocol -from adcp.types.domains.enums.co_branding_requirement import CoBrandingRequirement -from adcp.types.domains.enums.collection_cadence import CollectionCadence -from adcp.types.domains.enums.collection_kind import CollectionKind -from adcp.types.domains.enums.collection_relationship import CollectionRelationship -from adcp.types.domains.enums.collection_status import CollectionStatus -from adcp.types.domains.enums.completion_source import CompletionSource -from adcp.types.domains.enums.consent_basis import ConsentBasis -from adcp.types.domains.enums.content_id_type import ContentIdType -from adcp.types.domains.enums.content_rating_system import ContentRatingSystem -from adcp.types.domains.enums.creative_action import CreativeAction -from adcp.types.domains.enums.creative_agent_capability import CreativeAgentCapability -from adcp.types.domains.enums.creative_approval_status import CreativeApprovalStatus -from adcp.types.domains.enums.creative_event_reason_code import CreativeEventReasonCode -from adcp.types.domains.enums.creative_identifier_type import CreativeIdentifierType -from adcp.types.domains.enums.creative_quality import CreativeQuality -from adcp.types.domains.enums.creative_selection_strategy import CreativeSelectionStrategy -from adcp.types.domains.enums.creative_sort_field import CreativeSortField -from adcp.types.domains.enums.creative_status import CreativeStatus -from adcp.types.domains.enums.ctv_ad_experience import CtvAdExperience -from adcp.types.domains.enums.daast_tracking_event import DaastTrackingEvent -from adcp.types.domains.enums.daast_version import DaastVersion -from adcp.types.domains.enums.day_of_week import DayOfWeek -from adcp.types.domains.enums.daypart_timezone_mode import DaypartTimezoneMode -from adcp.types.domains.enums.delegation_authority import DelegationAuthority -from adcp.types.domains.enums.delivery_recipient_cloud import DeliveryRecipientCloud -from adcp.types.domains.enums.delivery_status import DeliveryStatus -from adcp.types.domains.enums.delivery_type import DeliveryType -from adcp.types.domains.enums.demographic_system import DemographicSystem -from adcp.types.domains.enums.derivative_type import DerivativeType -from adcp.types.domains.enums.device_platform import DevicePlatform -from adcp.types.domains.enums.device_type import DeviceType -from adcp.types.domains.enums.digital_source_type import DigitalSourceType -from adcp.types.domains.enums.dimension_unit import DimensionUnit -from adcp.types.domains.enums.disclosure_persistence import DisclosurePersistence -from adcp.types.domains.enums.disclosure_position import DisclosurePosition -from adcp.types.domains.enums.distance_unit import DistanceUnit -from adcp.types.domains.enums.distribution_identifier_type import DistributionIdentifierType -from adcp.types.domains.enums.dooh_motion_type import DoohMotionType -from adcp.types.domains.enums.embedded_provenance_method import EmbeddedProvenanceMethod -from adcp.types.domains.enums.error_code import ErrorCode -from adcp.types.domains.enums.error_scope import ErrorScope -from adcp.types.domains.enums.escalation_severity import EscalationSeverity -from adcp.types.domains.enums.event_type import EventType -from adcp.types.domains.enums.exclusivity import Exclusivity -from adcp.types.domains.enums.feature_check_status import FeatureCheckStatus -from adcp.types.domains.enums.feed_format import FeedFormat -from adcp.types.domains.enums.feedback_source import FeedbackSource -from adcp.types.domains.enums.forecast_method import ForecastMethod -from adcp.types.domains.enums.forecast_range_unit import ForecastRangeUnit -from adcp.types.domains.enums.forecastable_metric import ForecastableMetric -from adcp.types.domains.enums.format_id_parameter import FormatIdParameter -from adcp.types.domains.enums.frame_rate_type import FrameRateType -from adcp.types.domains.enums.frequency_cap_control_mode import FrequencyCapControlMode -from adcp.types.domains.enums.frequency_cap_mutable_field import FrequencyCapMutableField -from adcp.types.domains.enums.frequency_cap_scope import FrequencyCapScope -from adcp.types.domains.enums.genre_taxonomy import GenreTaxonomy -from adcp.types.domains.enums.geo_level import GeographicTargetingLevel -from adcp.types.domains.enums.gop_type import GopType -from adcp.types.domains.enums.governance_decision import GovernanceDecision -from adcp.types.domains.enums.governance_domain import GovernanceDomain -from adcp.types.domains.enums.governance_mode import GovernanceMode -from adcp.types.domains.enums.governance_phase import GovernancePhase -from adcp.types.domains.enums.history_entry_type import HistoryEntryType -from adcp.types.domains.enums.http_method import HttpMethod -from adcp.types.domains.enums.identifier_types import PropertyIdentifierTypes -from adcp.types.domains.enums.impairment_offline_state import ImpairmentOfflineState -from adcp.types.domains.enums.impairment_reason_code import ImpairmentReasonCode -from adcp.types.domains.enums.indicator_type import IndicatorType -from adcp.types.domains.enums.installment_status import InstallmentStatus -from adcp.types.domains.enums.javascript_module_type import JavascriptModuleType -from adcp.types.domains.enums.landing_page_requirement import LandingPageRequirement -from adcp.types.domains.enums.legacy_postal_system import CountryFusedPostalCodeSystem -from adcp.types.domains.enums.lift_dimension import LiftDimension -from adcp.types.domains.enums.logo_slot import LogoSlot -from adcp.types.domains.enums.macro_dialect import MacroDialectFamily -from adcp.types.domains.enums.macro_mapping_status import MacroMappingStatus -from adcp.types.domains.enums.macro_processing_operation import MacroProcessingOperation -from adcp.types.domains.enums.macro_resolution_reason import MacroResolutionReason -from adcp.types.domains.enums.macro_resolver import MacroProcessingActor -from adcp.types.domains.enums.macro_value_context import MacroValueContext -from adcp.types.domains.enums.makegood_remedy import MakegoodRemedy -from adcp.types.domains.enums.markdown_flavor import MarkdownFlavor -from adcp.types.domains.enums.match_id_type import MatchIdType -from adcp.types.domains.enums.match_type import MatchType -from adcp.types.domains.enums.media_buy_action_mode import MediaBuyActionMode -from adcp.types.domains.enums.media_buy_frequency_cap_control_mode import ( - MediaBuyFrequencyCapControlMode, -) -from adcp.types.domains.enums.media_buy_health import MediaBuyHealth -from adcp.types.domains.enums.media_buy_status import MediaBuyStatus -from adcp.types.domains.enums.media_buy_valid_action import MediaBuyValidAction -from adcp.types.domains.enums.metric_scope import MetricScope -from adcp.types.domains.enums.metric_type import MetricType -from adcp.types.domains.enums.metro_system import MetroAreaSystem -from adcp.types.domains.enums.moov_atom_position import MoovAtomPosition -from adcp.types.domains.enums.motion_level import CreativeMotionLevel -from adcp.types.domains.enums.notification_type import NotificationType -from adcp.types.domains.enums.offering_availability_status import OfferingAvailabilityStatus -from adcp.types.domains.enums.outcome_target_cost_strength import OutcomeTargetCostStrength -from adcp.types.domains.enums.outcome_type import OutcomeType -from adcp.types.domains.enums.pacing import Pacing -from adcp.types.domains.enums.payment_terms import PaymentTerms -from adcp.types.domains.enums.performance_baseline import PerformanceBaseline -from adcp.types.domains.enums.performance_standard_metric import PerformanceStandardMetric -from adcp.types.domains.enums.pixel_tracking_event import PixelTrackingEvent -from adcp.types.domains.enums.policy_category import PolicyCategory -from adcp.types.domains.enums.policy_enforcement import PolicyEnforcementLevel -from adcp.types.domains.enums.postal_system import PostalCodeSystem -from adcp.types.domains.enums.preview_output_format import PreviewOutputFormat -from adcp.types.domains.enums.pricing_model import PricingModel -from adcp.types.domains.enums.pricing_structure import PricingStructure -from adcp.types.domains.enums.principal_kind import PrincipalKind -from adcp.types.domains.enums.production_quality import ProductionQuality -from adcp.types.domains.enums.property_type import PropertyType -from adcp.types.domains.enums.proposal_decline_reason import ProposalDeclineReason -from adcp.types.domains.enums.proposal_refinement_reason import ProposalRefinementReason -from adcp.types.domains.enums.proposal_status import ProposalStatus -from adcp.types.domains.enums.publisher_identifier_types import PublisherIdentifierTypes -from adcp.types.domains.enums.purchase_type import PurchaseType -from adcp.types.domains.enums.reach_aggregation import ReachAggregation -from adcp.types.domains.enums.reach_unit import ReachUnit -from adcp.types.domains.enums.reporting_destination_setup_state import ( - ReportingDestinationSetupState, -) -from adcp.types.domains.enums.reporting_finality import ReportingFinality -from adcp.types.domains.enums.reporting_frequency import ReportingFrequency -from adcp.types.domains.enums.reporting_health import ReportingHealth -from adcp.types.domains.enums.representation_selection_strategy import ( - RepresentationSelectionStrategy, -) -from adcp.types.domains.enums.request_signing_error_code import RequestSigningErrorCode -from adcp.types.domains.enums.response_type import TmpResponseType -from adcp.types.domains.enums.restricted_attribute import RestrictedAttribute -from adcp.types.domains.enums.right_type import RightType -from adcp.types.domains.enums.right_use import RightUse -from adcp.types.domains.enums.rights_billing_period import RightsBillingPeriod -from adcp.types.domains.enums.scan_type import ScanType -from adcp.types.domains.enums.seller_policy_decline_reason import SellerPolicyDeclineReason -from adcp.types.domains.enums.si_session_status import SiSessionStatus -from adcp.types.domains.enums.signal_catalog_type import SignalAvailabilityType, SignalCatalogType -from adcp.types.domains.enums.signal_source import SignalSource -from adcp.types.domains.enums.signal_value_type import SignalValueType -from adcp.types.domains.enums.snapshot_unavailable_reason import SnapshotUnavailableReason -from adcp.types.domains.enums.social_placement_surface import SocialPlacementSurface -from adcp.types.domains.enums.sort_direction import SortDirection -from adcp.types.domains.enums.sort_metric import SortMetric -from adcp.types.domains.enums.special_category import SpecialCategory -from adcp.types.domains.enums.specialism import AdcpSpecialism -from adcp.types.domains.enums.sponsored_placement_type import SponsoredPlacementType -from adcp.types.domains.enums.talent_role import TalentRole -from adcp.types.domains.enums.task_status import TaskStatus -from adcp.types.domains.enums.task_type import TaskType -from adcp.types.domains.enums.tracker_execution_actor import TrackerExecutionActor -from adcp.types.domains.enums.tracker_firing_path import TrackerFiringPath -from adcp.types.domains.enums.transport_mode import TransportMode -from adcp.types.domains.enums.travel_time_unit import TravelTimeUnit -from adcp.types.domains.enums.uid_type import UidType -from adcp.types.domains.enums.universal_macro import UniversalMacro -from adcp.types.domains.enums.update_frequency import UpdateFrequency -from adcp.types.domains.enums.url_asset_type import UrlAssetType -from adcp.types.domains.enums.validation_mode import ValidationMode -from adcp.types.domains.enums.vast_media_delivery_method import VastMediaDeliveryMethod -from adcp.types.domains.enums.vast_tracking_event import VastTrackingEvent -from adcp.types.domains.enums.vast_version import VastVersion -from adcp.types.domains.enums.vendor_relationship import VendorRelationship -from adcp.types.domains.enums.video_placement_type import VideoPlacementType -from adcp.types.domains.enums.view_threshold_basis import ViewThresholdBasis -from adcp.types.domains.enums.viewability_standard import ViewabilityStandard -from adcp.types.domains.enums.warning_code import WarningCode -from adcp.types.domains.enums.watermark_media_type import WatermarkMediaType -from adcp.types.domains.enums.wcag_level import WcagLevel -from adcp.types.domains.enums.webhook_response_type import WebhookResponseType -from adcp.types.domains.enums.webhook_security_method import WebhookSecurityMethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.enums.account_currency_mode import AccountCurrencyMode + from adcp.types.domains.enums.account_scope import AccountScope + from adcp.types.domains.enums.account_status import AccountStatus + from adcp.types.domains.enums.action_not_allowed_reason import ActionNotAllowedReason + from adcp.types.domains.enums.action_source import ActionSource + from adcp.types.domains.enums.activation_method import CreativeActivationMethod + from adcp.types.domains.enums.adcp_protocol import AdcpProtocol + from adcp.types.domains.enums.adjustment_kind import PriceAdjustmentKind + from adcp.types.domains.enums.advertiser_industry import AdvertiserIndustry + from adcp.types.domains.enums.age_determination_basis import AgeDeterminationBasis + from adcp.types.domains.enums.age_verification_method import AgeVerificationMethod + from adcp.types.domains.enums.assessment_status import AssessmentStatus + from adcp.types.domains.enums.asset_content_type import AssetContentType + from adcp.types.domains.enums.attestation_claim import AttestationClaim + from adcp.types.domains.enums.attribution_methodology import AttributionMethodology + from adcp.types.domains.enums.attribution_model import AttributionModel + from adcp.types.domains.enums.audience_evidence_methodology import AudienceEvidenceMethodology + from adcp.types.domains.enums.audience_resolution_method import AudienceResolutionMethod + from adcp.types.domains.enums.audience_source import AudienceSource + from adcp.types.domains.enums.audience_status import AudienceStatus + from adcp.types.domains.enums.audience_subject_type import AudienceSubjectType + from adcp.types.domains.enums.audio_channel_layout import AudioChannelLayout + from adcp.types.domains.enums.audio_distribution_type import AudioDistributionType + from adcp.types.domains.enums.auth_scheme import AuthenticationScheme + from adcp.types.domains.enums.availability_status import AvailabilityStatus + from adcp.types.domains.enums.available_metric import AvailableMetric + from adcp.types.domains.enums.billing_party import BillingParty + from adcp.types.domains.enums.binary_verdict import BinaryVerdict + from adcp.types.domains.enums.brand_agent_type import BrandAgentType + from adcp.types.domains.enums.browser_family import BrowserFamily + from adcp.types.domains.enums.c2pa_watermark_action import C2PaWatermarkAction + from adcp.types.domains.enums.canceled_by import CanceledBy + from adcp.types.domains.enums.canonical_media_buy_action import CanonicalMediaBuyActionName + from adcp.types.domains.enums.canonical_media_buy_action_mode import CanonicalMediaBuyActionMode + from adcp.types.domains.enums.catalog_action import CatalogAction + from adcp.types.domains.enums.catalog_item_status import CatalogItemStatus + from adcp.types.domains.enums.catalog_type import CatalogType + from adcp.types.domains.enums.channels import MediaChannel + from adcp.types.domains.enums.cloud_storage_protocol import CloudStorageProtocol + from adcp.types.domains.enums.co_branding_requirement import CoBrandingRequirement + from adcp.types.domains.enums.collection_cadence import CollectionCadence + from adcp.types.domains.enums.collection_kind import CollectionKind + from adcp.types.domains.enums.collection_relationship import CollectionRelationship + from adcp.types.domains.enums.collection_status import CollectionStatus + from adcp.types.domains.enums.completion_source import CompletionSource + from adcp.types.domains.enums.consent_basis import ConsentBasis + from adcp.types.domains.enums.content_id_type import ContentIdType + from adcp.types.domains.enums.content_rating_system import ContentRatingSystem + from adcp.types.domains.enums.creative_action import CreativeAction + from adcp.types.domains.enums.creative_agent_capability import CreativeAgentCapability + from adcp.types.domains.enums.creative_approval_status import CreativeApprovalStatus + from adcp.types.domains.enums.creative_event_reason_code import CreativeEventReasonCode + from adcp.types.domains.enums.creative_identifier_type import CreativeIdentifierType + from adcp.types.domains.enums.creative_quality import CreativeQuality + from adcp.types.domains.enums.creative_selection_strategy import CreativeSelectionStrategy + from adcp.types.domains.enums.creative_sort_field import CreativeSortField + from adcp.types.domains.enums.creative_status import CreativeStatus + from adcp.types.domains.enums.ctv_ad_experience import CtvAdExperience + from adcp.types.domains.enums.daast_tracking_event import DaastTrackingEvent + from adcp.types.domains.enums.daast_version import DaastVersion + from adcp.types.domains.enums.day_of_week import DayOfWeek + from adcp.types.domains.enums.daypart_timezone_mode import DaypartTimezoneMode + from adcp.types.domains.enums.delegation_authority import DelegationAuthority + from adcp.types.domains.enums.delivery_recipient_cloud import DeliveryRecipientCloud + from adcp.types.domains.enums.delivery_status import DeliveryStatus + from adcp.types.domains.enums.delivery_type import DeliveryType + from adcp.types.domains.enums.demographic_system import DemographicSystem + from adcp.types.domains.enums.derivative_type import DerivativeType + from adcp.types.domains.enums.device_platform import DevicePlatform + from adcp.types.domains.enums.device_type import DeviceType + from adcp.types.domains.enums.digital_source_type import DigitalSourceType + from adcp.types.domains.enums.dimension_unit import DimensionUnit + from adcp.types.domains.enums.disclosure_persistence import DisclosurePersistence + from adcp.types.domains.enums.disclosure_position import DisclosurePosition + from adcp.types.domains.enums.distance_unit import DistanceUnit + from adcp.types.domains.enums.distribution_identifier_type import DistributionIdentifierType + from adcp.types.domains.enums.dooh_motion_type import DoohMotionType + from adcp.types.domains.enums.embedded_provenance_method import EmbeddedProvenanceMethod + from adcp.types.domains.enums.error_code import ErrorCode + from adcp.types.domains.enums.error_scope import ErrorScope + from adcp.types.domains.enums.escalation_severity import EscalationSeverity + from adcp.types.domains.enums.event_type import EventType + from adcp.types.domains.enums.exclusivity import Exclusivity + from adcp.types.domains.enums.feature_check_status import FeatureCheckStatus + from adcp.types.domains.enums.feed_format import FeedFormat + from adcp.types.domains.enums.feedback_source import FeedbackSource + from adcp.types.domains.enums.forecast_method import ForecastMethod + from adcp.types.domains.enums.forecast_range_unit import ForecastRangeUnit + from adcp.types.domains.enums.forecastable_metric import ForecastableMetric + from adcp.types.domains.enums.format_id_parameter import FormatIdParameter + from adcp.types.domains.enums.frame_rate_type import FrameRateType + from adcp.types.domains.enums.frequency_cap_control_mode import FrequencyCapControlMode + from adcp.types.domains.enums.frequency_cap_mutable_field import FrequencyCapMutableField + from adcp.types.domains.enums.frequency_cap_scope import FrequencyCapScope + from adcp.types.domains.enums.genre_taxonomy import GenreTaxonomy + from adcp.types.domains.enums.geo_level import GeographicTargetingLevel + from adcp.types.domains.enums.gop_type import GopType + from adcp.types.domains.enums.governance_decision import GovernanceDecision + from adcp.types.domains.enums.governance_domain import GovernanceDomain + from adcp.types.domains.enums.governance_mode import GovernanceMode + from adcp.types.domains.enums.governance_phase import GovernancePhase + from adcp.types.domains.enums.history_entry_type import HistoryEntryType + from adcp.types.domains.enums.http_method import HttpMethod + from adcp.types.domains.enums.identifier_types import PropertyIdentifierTypes + from adcp.types.domains.enums.impairment_offline_state import ImpairmentOfflineState + from adcp.types.domains.enums.impairment_reason_code import ImpairmentReasonCode + from adcp.types.domains.enums.indicator_type import IndicatorType + from adcp.types.domains.enums.installment_status import InstallmentStatus + from adcp.types.domains.enums.javascript_module_type import JavascriptModuleType + from adcp.types.domains.enums.landing_page_requirement import LandingPageRequirement + from adcp.types.domains.enums.legacy_postal_system import CountryFusedPostalCodeSystem + from adcp.types.domains.enums.lift_dimension import LiftDimension + from adcp.types.domains.enums.logo_slot import LogoSlot + from adcp.types.domains.enums.macro_dialect import MacroDialectFamily + from adcp.types.domains.enums.macro_mapping_status import MacroMappingStatus + from adcp.types.domains.enums.macro_processing_operation import MacroProcessingOperation + from adcp.types.domains.enums.macro_resolution_reason import MacroResolutionReason + from adcp.types.domains.enums.macro_resolver import MacroProcessingActor + from adcp.types.domains.enums.macro_value_context import MacroValueContext + from adcp.types.domains.enums.makegood_remedy import MakegoodRemedy + from adcp.types.domains.enums.markdown_flavor import MarkdownFlavor + from adcp.types.domains.enums.match_id_type import MatchIdType + from adcp.types.domains.enums.match_type import MatchType + from adcp.types.domains.enums.media_buy_action_mode import MediaBuyActionMode + from adcp.types.domains.enums.media_buy_frequency_cap_control_mode import ( + MediaBuyFrequencyCapControlMode, + ) + from adcp.types.domains.enums.media_buy_health import MediaBuyHealth + from adcp.types.domains.enums.media_buy_status import MediaBuyStatus + from adcp.types.domains.enums.media_buy_valid_action import MediaBuyValidAction + from adcp.types.domains.enums.metric_scope import MetricScope + from adcp.types.domains.enums.metric_type import MetricType + from adcp.types.domains.enums.metro_system import MetroAreaSystem + from adcp.types.domains.enums.moov_atom_position import MoovAtomPosition + from adcp.types.domains.enums.motion_level import CreativeMotionLevel + from adcp.types.domains.enums.notification_type import NotificationType + from adcp.types.domains.enums.offering_availability_status import OfferingAvailabilityStatus + from adcp.types.domains.enums.outcome_target_cost_strength import OutcomeTargetCostStrength + from adcp.types.domains.enums.outcome_type import OutcomeType + from adcp.types.domains.enums.pacing import Pacing + from adcp.types.domains.enums.payment_terms import PaymentTerms + from adcp.types.domains.enums.performance_baseline import PerformanceBaseline + from adcp.types.domains.enums.performance_standard_metric import PerformanceStandardMetric + from adcp.types.domains.enums.pixel_tracking_event import PixelTrackingEvent + from adcp.types.domains.enums.policy_category import PolicyCategory + from adcp.types.domains.enums.policy_enforcement import PolicyEnforcementLevel + from adcp.types.domains.enums.postal_system import PostalCodeSystem + from adcp.types.domains.enums.preview_output_format import PreviewOutputFormat + from adcp.types.domains.enums.pricing_model import PricingModel + from adcp.types.domains.enums.pricing_structure import PricingStructure + from adcp.types.domains.enums.principal_kind import PrincipalKind + from adcp.types.domains.enums.production_quality import ProductionQuality + from adcp.types.domains.enums.property_type import PropertyType + from adcp.types.domains.enums.proposal_decline_reason import ProposalDeclineReason + from adcp.types.domains.enums.proposal_refinement_reason import ProposalRefinementReason + from adcp.types.domains.enums.proposal_status import ProposalStatus + from adcp.types.domains.enums.publisher_identifier_types import PublisherIdentifierTypes + from adcp.types.domains.enums.purchase_type import PurchaseType + from adcp.types.domains.enums.reach_aggregation import ReachAggregation + from adcp.types.domains.enums.reach_unit import ReachUnit + from adcp.types.domains.enums.reporting_destination_setup_state import ( + ReportingDestinationSetupState, + ) + from adcp.types.domains.enums.reporting_finality import ReportingFinality + from adcp.types.domains.enums.reporting_frequency import ReportingFrequency + from adcp.types.domains.enums.reporting_health import ReportingHealth + from adcp.types.domains.enums.representation_selection_strategy import ( + RepresentationSelectionStrategy, + ) + from adcp.types.domains.enums.request_signing_error_code import RequestSigningErrorCode + from adcp.types.domains.enums.response_type import TmpResponseType + from adcp.types.domains.enums.restricted_attribute import RestrictedAttribute + from adcp.types.domains.enums.right_type import RightType + from adcp.types.domains.enums.right_use import RightUse + from adcp.types.domains.enums.rights_billing_period import RightsBillingPeriod + from adcp.types.domains.enums.scan_type import ScanType + from adcp.types.domains.enums.seller_policy_decline_reason import SellerPolicyDeclineReason + from adcp.types.domains.enums.si_session_status import SiSessionStatus + from adcp.types.domains.enums.signal_catalog_type import ( + SignalAvailabilityType, + SignalCatalogType, + ) + from adcp.types.domains.enums.signal_source import SignalSource + from adcp.types.domains.enums.signal_value_type import SignalValueType + from adcp.types.domains.enums.snapshot_unavailable_reason import SnapshotUnavailableReason + from adcp.types.domains.enums.social_placement_surface import SocialPlacementSurface + from adcp.types.domains.enums.sort_direction import SortDirection + from adcp.types.domains.enums.sort_metric import SortMetric + from adcp.types.domains.enums.special_category import SpecialCategory + from adcp.types.domains.enums.specialism import AdcpSpecialism + from adcp.types.domains.enums.sponsored_placement_type import SponsoredPlacementType + from adcp.types.domains.enums.talent_role import TalentRole + from adcp.types.domains.enums.task_status import TaskStatus + from adcp.types.domains.enums.task_type import TaskType + from adcp.types.domains.enums.tracker_execution_actor import TrackerExecutionActor + from adcp.types.domains.enums.tracker_firing_path import TrackerFiringPath + from adcp.types.domains.enums.transport_mode import TransportMode + from adcp.types.domains.enums.travel_time_unit import TravelTimeUnit + from adcp.types.domains.enums.uid_type import UidType + from adcp.types.domains.enums.universal_macro import UniversalMacro + from adcp.types.domains.enums.update_frequency import UpdateFrequency + from adcp.types.domains.enums.url_asset_type import UrlAssetType + from adcp.types.domains.enums.validation_mode import ValidationMode + from adcp.types.domains.enums.vast_media_delivery_method import VastMediaDeliveryMethod + from adcp.types.domains.enums.vast_tracking_event import VastTrackingEvent + from adcp.types.domains.enums.vast_version import VastVersion + from adcp.types.domains.enums.vendor_relationship import VendorRelationship + from adcp.types.domains.enums.video_placement_type import VideoPlacementType + from adcp.types.domains.enums.view_threshold_basis import ViewThresholdBasis + from adcp.types.domains.enums.viewability_standard import ViewabilityStandard + from adcp.types.domains.enums.warning_code import WarningCode + from adcp.types.domains.enums.watermark_media_type import WatermarkMediaType + from adcp.types.domains.enums.wcag_level import WcagLevel + from adcp.types.domains.enums.webhook_response_type import WebhookResponseType + from adcp.types.domains.enums.webhook_security_method import WebhookSecurityMethod + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "enums.account_currency_mode": ("AccountCurrencyMode",), + "enums.account_scope": ("AccountScope",), + "enums.account_status": ("AccountStatus",), + "enums.action_not_allowed_reason": ("ActionNotAllowedReason",), + "enums.action_source": ("ActionSource",), + "enums.activation_method": ("CreativeActivationMethod",), + "enums.adcp_protocol": ("AdcpProtocol",), + "enums.adjustment_kind": ("PriceAdjustmentKind",), + "enums.advertiser_industry": ("AdvertiserIndustry",), + "enums.age_determination_basis": ("AgeDeterminationBasis",), + "enums.age_verification_method": ("AgeVerificationMethod",), + "enums.assessment_status": ("AssessmentStatus",), + "enums.asset_content_type": ("AssetContentType",), + "enums.attestation_claim": ("AttestationClaim",), + "enums.attribution_methodology": ("AttributionMethodology",), + "enums.attribution_model": ("AttributionModel",), + "enums.audience_evidence_methodology": ("AudienceEvidenceMethodology",), + "enums.audience_resolution_method": ("AudienceResolutionMethod",), + "enums.audience_source": ("AudienceSource",), + "enums.audience_status": ("AudienceStatus",), + "enums.audience_subject_type": ("AudienceSubjectType",), + "enums.audio_channel_layout": ("AudioChannelLayout",), + "enums.audio_distribution_type": ("AudioDistributionType",), + "enums.auth_scheme": ("AuthenticationScheme",), + "enums.availability_status": ("AvailabilityStatus",), + "enums.available_metric": ("AvailableMetric",), + "enums.billing_party": ("BillingParty",), + "enums.binary_verdict": ("BinaryVerdict",), + "enums.brand_agent_type": ("BrandAgentType",), + "enums.browser_family": ("BrowserFamily",), + "enums.c2pa_watermark_action": ("C2PaWatermarkAction",), + "enums.canceled_by": ("CanceledBy",), + "enums.canonical_media_buy_action": ("CanonicalMediaBuyActionName",), + "enums.canonical_media_buy_action_mode": ("CanonicalMediaBuyActionMode",), + "enums.catalog_action": ("CatalogAction",), + "enums.catalog_item_status": ("CatalogItemStatus",), + "enums.catalog_type": ("CatalogType",), + "enums.channels": ("MediaChannel",), + "enums.cloud_storage_protocol": ("CloudStorageProtocol",), + "enums.co_branding_requirement": ("CoBrandingRequirement",), + "enums.collection_cadence": ("CollectionCadence",), + "enums.collection_kind": ("CollectionKind",), + "enums.collection_relationship": ("CollectionRelationship",), + "enums.collection_status": ("CollectionStatus",), + "enums.completion_source": ("CompletionSource",), + "enums.consent_basis": ("ConsentBasis",), + "enums.content_id_type": ("ContentIdType",), + "enums.content_rating_system": ("ContentRatingSystem",), + "enums.creative_action": ("CreativeAction",), + "enums.creative_agent_capability": ("CreativeAgentCapability",), + "enums.creative_approval_status": ("CreativeApprovalStatus",), + "enums.creative_event_reason_code": ("CreativeEventReasonCode",), + "enums.creative_identifier_type": ("CreativeIdentifierType",), + "enums.creative_quality": ("CreativeQuality",), + "enums.creative_selection_strategy": ("CreativeSelectionStrategy",), + "enums.creative_sort_field": ("CreativeSortField",), + "enums.creative_status": ("CreativeStatus",), + "enums.ctv_ad_experience": ("CtvAdExperience",), + "enums.daast_tracking_event": ("DaastTrackingEvent",), + "enums.daast_version": ("DaastVersion",), + "enums.day_of_week": ("DayOfWeek",), + "enums.daypart_timezone_mode": ("DaypartTimezoneMode",), + "enums.delegation_authority": ("DelegationAuthority",), + "enums.delivery_recipient_cloud": ("DeliveryRecipientCloud",), + "enums.delivery_status": ("DeliveryStatus",), + "enums.delivery_type": ("DeliveryType",), + "enums.demographic_system": ("DemographicSystem",), + "enums.derivative_type": ("DerivativeType",), + "enums.device_platform": ("DevicePlatform",), + "enums.device_type": ("DeviceType",), + "enums.digital_source_type": ("DigitalSourceType",), + "enums.dimension_unit": ("DimensionUnit",), + "enums.disclosure_persistence": ("DisclosurePersistence",), + "enums.disclosure_position": ("DisclosurePosition",), + "enums.distance_unit": ("DistanceUnit",), + "enums.distribution_identifier_type": ("DistributionIdentifierType",), + "enums.dooh_motion_type": ("DoohMotionType",), + "enums.embedded_provenance_method": ("EmbeddedProvenanceMethod",), + "enums.error_code": ("ErrorCode",), + "enums.error_scope": ("ErrorScope",), + "enums.escalation_severity": ("EscalationSeverity",), + "enums.event_type": ("EventType",), + "enums.exclusivity": ("Exclusivity",), + "enums.feature_check_status": ("FeatureCheckStatus",), + "enums.feed_format": ("FeedFormat",), + "enums.feedback_source": ("FeedbackSource",), + "enums.forecast_method": ("ForecastMethod",), + "enums.forecast_range_unit": ("ForecastRangeUnit",), + "enums.forecastable_metric": ("ForecastableMetric",), + "enums.format_id_parameter": ("FormatIdParameter",), + "enums.frame_rate_type": ("FrameRateType",), + "enums.frequency_cap_control_mode": ("FrequencyCapControlMode",), + "enums.frequency_cap_mutable_field": ("FrequencyCapMutableField",), + "enums.frequency_cap_scope": ("FrequencyCapScope",), + "enums.genre_taxonomy": ("GenreTaxonomy",), + "enums.geo_level": ("GeographicTargetingLevel",), + "enums.gop_type": ("GopType",), + "enums.governance_decision": ("GovernanceDecision",), + "enums.governance_domain": ("GovernanceDomain",), + "enums.governance_mode": ("GovernanceMode",), + "enums.governance_phase": ("GovernancePhase",), + "enums.history_entry_type": ("HistoryEntryType",), + "enums.http_method": ("HttpMethod",), + "enums.identifier_types": ("PropertyIdentifierTypes",), + "enums.impairment_offline_state": ("ImpairmentOfflineState",), + "enums.impairment_reason_code": ("ImpairmentReasonCode",), + "enums.indicator_type": ("IndicatorType",), + "enums.installment_status": ("InstallmentStatus",), + "enums.javascript_module_type": ("JavascriptModuleType",), + "enums.landing_page_requirement": ("LandingPageRequirement",), + "enums.legacy_postal_system": ("CountryFusedPostalCodeSystem",), + "enums.lift_dimension": ("LiftDimension",), + "enums.logo_slot": ("LogoSlot",), + "enums.macro_dialect": ("MacroDialectFamily",), + "enums.macro_mapping_status": ("MacroMappingStatus",), + "enums.macro_processing_operation": ("MacroProcessingOperation",), + "enums.macro_resolution_reason": ("MacroResolutionReason",), + "enums.macro_resolver": ("MacroProcessingActor",), + "enums.macro_value_context": ("MacroValueContext",), + "enums.makegood_remedy": ("MakegoodRemedy",), + "enums.markdown_flavor": ("MarkdownFlavor",), + "enums.match_id_type": ("MatchIdType",), + "enums.match_type": ("MatchType",), + "enums.media_buy_action_mode": ("MediaBuyActionMode",), + "enums.media_buy_frequency_cap_control_mode": ("MediaBuyFrequencyCapControlMode",), + "enums.media_buy_health": ("MediaBuyHealth",), + "enums.media_buy_status": ("MediaBuyStatus",), + "enums.media_buy_valid_action": ("MediaBuyValidAction",), + "enums.metric_scope": ("MetricScope",), + "enums.metric_type": ("MetricType",), + "enums.metro_system": ("MetroAreaSystem",), + "enums.moov_atom_position": ("MoovAtomPosition",), + "enums.motion_level": ("CreativeMotionLevel",), + "enums.notification_type": ("NotificationType",), + "enums.offering_availability_status": ("OfferingAvailabilityStatus",), + "enums.outcome_target_cost_strength": ("OutcomeTargetCostStrength",), + "enums.outcome_type": ("OutcomeType",), + "enums.pacing": ("Pacing",), + "enums.payment_terms": ("PaymentTerms",), + "enums.performance_baseline": ("PerformanceBaseline",), + "enums.performance_standard_metric": ("PerformanceStandardMetric",), + "enums.pixel_tracking_event": ("PixelTrackingEvent",), + "enums.policy_category": ("PolicyCategory",), + "enums.policy_enforcement": ("PolicyEnforcementLevel",), + "enums.postal_system": ("PostalCodeSystem",), + "enums.preview_output_format": ("PreviewOutputFormat",), + "enums.pricing_model": ("PricingModel",), + "enums.pricing_structure": ("PricingStructure",), + "enums.principal_kind": ("PrincipalKind",), + "enums.production_quality": ("ProductionQuality",), + "enums.property_type": ("PropertyType",), + "enums.proposal_decline_reason": ("ProposalDeclineReason",), + "enums.proposal_refinement_reason": ("ProposalRefinementReason",), + "enums.proposal_status": ("ProposalStatus",), + "enums.publisher_identifier_types": ("PublisherIdentifierTypes",), + "enums.purchase_type": ("PurchaseType",), + "enums.reach_aggregation": ("ReachAggregation",), + "enums.reach_unit": ("ReachUnit",), + "enums.reporting_destination_setup_state": ("ReportingDestinationSetupState",), + "enums.reporting_finality": ("ReportingFinality",), + "enums.reporting_frequency": ("ReportingFrequency",), + "enums.reporting_health": ("ReportingHealth",), + "enums.representation_selection_strategy": ("RepresentationSelectionStrategy",), + "enums.request_signing_error_code": ("RequestSigningErrorCode",), + "enums.response_type": ("TmpResponseType",), + "enums.restricted_attribute": ("RestrictedAttribute",), + "enums.right_type": ("RightType",), + "enums.right_use": ("RightUse",), + "enums.rights_billing_period": ("RightsBillingPeriod",), + "enums.scan_type": ("ScanType",), + "enums.seller_policy_decline_reason": ("SellerPolicyDeclineReason",), + "enums.si_session_status": ("SiSessionStatus",), + "enums.signal_catalog_type": ( + "SignalAvailabilityType", + "SignalCatalogType", + ), + "enums.signal_source": ("SignalSource",), + "enums.signal_value_type": ("SignalValueType",), + "enums.snapshot_unavailable_reason": ("SnapshotUnavailableReason",), + "enums.social_placement_surface": ("SocialPlacementSurface",), + "enums.sort_direction": ("SortDirection",), + "enums.sort_metric": ("SortMetric",), + "enums.special_category": ("SpecialCategory",), + "enums.specialism": ("AdcpSpecialism",), + "enums.sponsored_placement_type": ("SponsoredPlacementType",), + "enums.talent_role": ("TalentRole",), + "enums.task_status": ("TaskStatus",), + "enums.task_type": ("TaskType",), + "enums.tracker_execution_actor": ("TrackerExecutionActor",), + "enums.tracker_firing_path": ("TrackerFiringPath",), + "enums.transport_mode": ("TransportMode",), + "enums.travel_time_unit": ("TravelTimeUnit",), + "enums.uid_type": ("UidType",), + "enums.universal_macro": ("UniversalMacro",), + "enums.update_frequency": ("UpdateFrequency",), + "enums.url_asset_type": ("UrlAssetType",), + "enums.validation_mode": ("ValidationMode",), + "enums.vast_media_delivery_method": ("VastMediaDeliveryMethod",), + "enums.vast_tracking_event": ("VastTrackingEvent",), + "enums.vast_version": ("VastVersion",), + "enums.vendor_relationship": ("VendorRelationship",), + "enums.video_placement_type": ("VideoPlacementType",), + "enums.view_threshold_basis": ("ViewThresholdBasis",), + "enums.viewability_standard": ("ViewabilityStandard",), + "enums.warning_code": ("WarningCode",), + "enums.watermark_media_type": ("WatermarkMediaType",), + "enums.wcag_level": ("WcagLevel",), + "enums.webhook_response_type": ("WebhookResponseType",), + "enums.webhook_security_method": ("WebhookSecurityMethod",), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/error_details/__init__.py b/src/adcp/types/domains/error_details/__init__.py index d956e4d80..31b68d761 100644 --- a/src/adcp/types/domains/error_details/__init__.py +++ b/src/adcp/types/domains/error_details/__init__.py @@ -9,85 +9,203 @@ it from its own schema's module, ``adcp.types.domains.error_details.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.error_details.accessibility_violation import ( - AccessibilityViolationDetails, - FailureKind, - FailureKind1, - Violation, -) -from adcp.types.domains.error_details.account_moved import AccountMovedDetails -from adcp.types.domains.error_details.account_setup_required import AccountSetupRequiredDetails -from adcp.types.domains.error_details.action_not_allowed import ActionNotAllowedDetails -from adcp.types.domains.error_details.agent_permission_denied import AgentPermissionDeniedDetails -from adcp.types.domains.error_details.audience_too_small import AudienceTooSmallDetails -from adcp.types.domains.error_details.authorization_required import AuthorizationRequiredDetails -from adcp.types.domains.error_details.billing_not_permitted_for_agent import ( - BillingNotPermittedForAgentDetails, -) -from adcp.types.domains.error_details.billing_not_supported import BillingNotSupportedDetails -from adcp.types.domains.error_details.budget_too_low import BudgetTooLowDetails -from adcp.types.domains.error_details.conflict import ConflictDetails -from adcp.types.domains.error_details.creative_rejected import CreativeRejectedDetails -from adcp.types.domains.error_details.creative_representation_unresolved import ( - CreativeRepresentationUnresolvedDetails, -) -from adcp.types.domains.error_details.creative_revision_content_mismatch import ( - CreativeRevisionContentMismatchDetails, -) -from adcp.types.domains.error_details.execution_requirement_unmet import ( - ExecutionRequirementUnmetDetails, - Reason, - UnmetRequirement, -) -from adcp.types.domains.error_details.governance_agent_not_accepted import ( - GovernanceAgentNotAcceptedDetails, - GovernanceAgentNotAcceptedDetails1, - GovernanceAgentNotAcceptedDetails2, -) -from adcp.types.domains.error_details.macro_resolution_failed import MacroResolutionFailedDetails -from adcp.types.domains.error_details.policy_violation import Origin, PolicyViolationDetails -from adcp.types.domains.error_details.rate_limited import RateLimitedDetails -from adcp.types.domains.error_details.requote_required import ( - EnvelopeField, - EnvelopeField1, - EnvelopeField1Item, - RequoteRequiredDetails, -) -from adcp.types.domains.error_details.stale_response import ( - OriginalError, - StaleResponseDetails, - Upstream, -) -from adcp.types.domains.error_details.unsupported_refinement_dimension import ( - SupportedDimension, - UnsupportedRefinementDimensionDetails, -) -from adcp.types.domains.error_details.vast_version_mismatch import ( - DocumentRole, - MismatchReason, - VastVersionMismatchDetails, - VastVersionMismatchDetails1, - VastVersionMismatchDetails2, - VastVersionMismatchDetails3, -) -from adcp.types.domains.error_details.vendor_error_codes import ( - Codes, - Recovery, - VendorErrorCodeRegistry, - Vendors, -) -from adcp.types.domains.error_details.version_unsupported import ( - SupportedMajor, - SupportedVersion, - VersionUnsupportedDetails, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.error_details.accessibility_violation import ( + AccessibilityViolationDetails, + FailureKind, + FailureKind1, + Violation, + ) + from adcp.types.domains.error_details.account_moved import AccountMovedDetails + from adcp.types.domains.error_details.account_setup_required import AccountSetupRequiredDetails + from adcp.types.domains.error_details.action_not_allowed import ActionNotAllowedDetails + from adcp.types.domains.error_details.agent_permission_denied import ( + AgentPermissionDeniedDetails, + ) + from adcp.types.domains.error_details.audience_too_small import AudienceTooSmallDetails + from adcp.types.domains.error_details.authorization_required import AuthorizationRequiredDetails + from adcp.types.domains.error_details.billing_not_permitted_for_agent import ( + BillingNotPermittedForAgentDetails, + ) + from adcp.types.domains.error_details.billing_not_supported import BillingNotSupportedDetails + from adcp.types.domains.error_details.budget_too_low import BudgetTooLowDetails + from adcp.types.domains.error_details.conflict import ConflictDetails + from adcp.types.domains.error_details.creative_rejected import CreativeRejectedDetails + from adcp.types.domains.error_details.creative_representation_unresolved import ( + CreativeRepresentationUnresolvedDetails, + ) + from adcp.types.domains.error_details.creative_revision_content_mismatch import ( + CreativeRevisionContentMismatchDetails, + ) + from adcp.types.domains.error_details.execution_requirement_unmet import ( + ExecutionRequirementUnmetDetails, + Reason, + UnmetRequirement, + ) + from adcp.types.domains.error_details.governance_agent_not_accepted import ( + GovernanceAgentNotAcceptedDetails, + GovernanceAgentNotAcceptedDetails1, + GovernanceAgentNotAcceptedDetails2, + ) + from adcp.types.domains.error_details.macro_resolution_failed import ( + MacroResolutionFailedDetails, + ) + from adcp.types.domains.error_details.policy_violation import Origin, PolicyViolationDetails + from adcp.types.domains.error_details.rate_limited import RateLimitedDetails + from adcp.types.domains.error_details.requote_required import ( + EnvelopeField, + EnvelopeField1, + EnvelopeField1Item, + RequoteRequiredDetails, + ) + from adcp.types.domains.error_details.stale_response import ( + OriginalError, + StaleResponseDetails, + Upstream, + ) + from adcp.types.domains.error_details.unsupported_refinement_dimension import ( + SupportedDimension, + UnsupportedRefinementDimensionDetails, + ) + from adcp.types.domains.error_details.vast_version_mismatch import ( + DocumentRole, + MismatchReason, + VastVersionMismatchDetails, + VastVersionMismatchDetails1, + VastVersionMismatchDetails2, + VastVersionMismatchDetails3, + ) + from adcp.types.domains.error_details.vendor_error_codes import ( + Codes, + Recovery, + VendorErrorCodeRegistry, + Vendors, + ) + from adcp.types.domains.error_details.version_unsupported import ( + SupportedMajor, + SupportedVersion, + VersionUnsupportedDetails, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "error_details.accessibility_violation": ( + "AccessibilityViolationDetails", + "FailureKind", + "FailureKind1", + "Violation", + ), + "error_details.account_moved": ("AccountMovedDetails",), + "error_details.account_setup_required": ("AccountSetupRequiredDetails",), + "error_details.action_not_allowed": ("ActionNotAllowedDetails",), + "error_details.agent_permission_denied": ("AgentPermissionDeniedDetails",), + "error_details.audience_too_small": ("AudienceTooSmallDetails",), + "error_details.authorization_required": ("AuthorizationRequiredDetails",), + "error_details.billing_not_permitted_for_agent": ("BillingNotPermittedForAgentDetails",), + "error_details.billing_not_supported": ("BillingNotSupportedDetails",), + "error_details.budget_too_low": ("BudgetTooLowDetails",), + "error_details.conflict": ("ConflictDetails",), + "error_details.creative_rejected": ("CreativeRejectedDetails",), + "error_details.creative_representation_unresolved": ( + "CreativeRepresentationUnresolvedDetails", + ), + "error_details.creative_revision_content_mismatch": ("CreativeRevisionContentMismatchDetails",), + "error_details.execution_requirement_unmet": ( + "ExecutionRequirementUnmetDetails", + "Reason", + "UnmetRequirement", + ), + "error_details.governance_agent_not_accepted": ( + "GovernanceAgentNotAcceptedDetails", + "GovernanceAgentNotAcceptedDetails1", + "GovernanceAgentNotAcceptedDetails2", + ), + "error_details.macro_resolution_failed": ("MacroResolutionFailedDetails",), + "error_details.policy_violation": ( + "Origin", + "PolicyViolationDetails", + ), + "error_details.rate_limited": ("RateLimitedDetails",), + "error_details.requote_required": ( + "EnvelopeField", + "EnvelopeField1", + "EnvelopeField1Item", + "RequoteRequiredDetails", + ), + "error_details.stale_response": ( + "OriginalError", + "StaleResponseDetails", + "Upstream", + ), + "error_details.unsupported_refinement_dimension": ( + "SupportedDimension", + "UnsupportedRefinementDimensionDetails", + ), + "error_details.vast_version_mismatch": ( + "DocumentRole", + "MismatchReason", + "VastVersionMismatchDetails", + "VastVersionMismatchDetails1", + "VastVersionMismatchDetails2", + "VastVersionMismatchDetails3", + ), + "error_details.vendor_error_codes": ( + "Codes", + "Recovery", + "VendorErrorCodeRegistry", + "Vendors", + ), + "error_details.version_unsupported": ( + "SupportedMajor", + "SupportedVersion", + "VersionUnsupportedDetails", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/extensions/__init__.py b/src/adcp/types/domains/extensions/__init__.py index 9e2e13967..8cf13d429 100644 --- a/src/adcp/types/domains/extensions/__init__.py +++ b/src/adcp/types/domains/extensions/__init__.py @@ -9,14 +9,58 @@ it from its own schema's module, ``adcp.types.domains.extensions.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.extensions.extension_meta import AdcpExtensionFileSchema +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.extensions.extension_meta import AdcpExtensionFileSchema + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "extensions.extension_meta": ("AdcpExtensionFileSchema",), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = ["AdcpExtensionFileSchema"] diff --git a/src/adcp/types/domains/formats/__init__.py b/src/adcp/types/domains/formats/__init__.py index 596c05ef5..21c78f49f 100644 --- a/src/adcp/types/domains/formats/__init__.py +++ b/src/adcp/types/domains/formats/__init__.py @@ -9,89 +9,206 @@ it from its own schema's module, ``adcp.types.domains.formats.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.formats.canonical._base import ( - CanonicalFormatBase, - CompositionModel, - ReferenceMutability, - RequiredPixelRatio, - Slot, -) -from adcp.types.domains.formats.canonical.agent_placement import ( - CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement, -) -from adcp.types.domains.formats.canonical.audio_daast import CanonicalFormatDaastAudio -from adcp.types.domains.formats.canonical.audio_hosted import CanonicalFormatHostedAudio -from adcp.types.domains.formats.canonical.audio_vast import CanonicalFormatVastAudio -from adcp.types.domains.formats.canonical.coordinated_placements import ( - AssetSource5, - AudioCodec2, - CanonicalFormatCoordinatedPlacements, - Components, - Components1, - Components10, - Components11, - Components12, - Components13, - Components2, - Components3, - Components4, - Components5, - Components6, - Components7, - Components8, - Components9, - ImageFormat1, - Params, - Params10, - Params11, - Params12, - Params13, - Params2, - Params3, - Params4, - Params5, - Params6, - Params7, - Params8, - Params9, - ServingPolicy, - SharedSlot, - TransitionMode3, - Transitions1, - Transitions2, - Transitions3, - Transitions4, - Transitions5, -) -from adcp.types.domains.formats.canonical.display_tag import CanonicalFormatDisplayTag -from adcp.types.domains.formats.canonical.html5 import CanonicalFormatHtml5Banner -from adcp.types.domains.formats.canonical.image import CanonicalFormatImage -from adcp.types.domains.formats.canonical.image_carousel import CanonicalFormatImageCarousel -from adcp.types.domains.formats.canonical.native_in_feed import CanonicalFormatNativeInFeed -from adcp.types.domains.formats.canonical.responsive_creative import ( - CanonicalFormatResponsiveCreative, -) -from adcp.types.domains.formats.canonical.seller_rendered_stateful_display import ( - CanonicalFormatSellerRenderedStatefulDisplay, - TransitionMode9, - Transitions10, - Transitions11, - Transitions7, - Transitions8, - Transitions9, -) -from adcp.types.domains.formats.canonical.sponsored_placement import ( - CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven, -) -from adcp.types.domains.formats.canonical.video_hosted import CanonicalFormatHostedVideo -from adcp.types.domains.formats.canonical.video_vast import CanonicalFormatVastVideo +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.formats.canonical._base import ( + CanonicalFormatBase, + CompositionModel, + ReferenceMutability, + RequiredPixelRatio, + Slot, + ) + from adcp.types.domains.formats.canonical.agent_placement import ( + CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement, + ) + from adcp.types.domains.formats.canonical.audio_daast import CanonicalFormatDaastAudio + from adcp.types.domains.formats.canonical.audio_hosted import CanonicalFormatHostedAudio + from adcp.types.domains.formats.canonical.audio_vast import CanonicalFormatVastAudio + from adcp.types.domains.formats.canonical.coordinated_placements import ( + AssetSource5, + AudioCodec2, + CanonicalFormatCoordinatedPlacements, + Components, + Components1, + Components10, + Components11, + Components12, + Components13, + Components2, + Components3, + Components4, + Components5, + Components6, + Components7, + Components8, + Components9, + ImageFormat1, + Params, + Params10, + Params11, + Params12, + Params13, + Params2, + Params3, + Params4, + Params5, + Params6, + Params7, + Params8, + Params9, + ServingPolicy, + SharedSlot, + TransitionMode3, + Transitions1, + Transitions2, + Transitions3, + Transitions4, + Transitions5, + ) + from adcp.types.domains.formats.canonical.display_tag import CanonicalFormatDisplayTag + from adcp.types.domains.formats.canonical.html5 import CanonicalFormatHtml5Banner + from adcp.types.domains.formats.canonical.image import CanonicalFormatImage + from adcp.types.domains.formats.canonical.image_carousel import CanonicalFormatImageCarousel + from adcp.types.domains.formats.canonical.native_in_feed import CanonicalFormatNativeInFeed + from adcp.types.domains.formats.canonical.responsive_creative import ( + CanonicalFormatResponsiveCreative, + ) + from adcp.types.domains.formats.canonical.seller_rendered_stateful_display import ( + CanonicalFormatSellerRenderedStatefulDisplay, + TransitionMode9, + Transitions10, + Transitions11, + Transitions7, + Transitions8, + Transitions9, + ) + from adcp.types.domains.formats.canonical.sponsored_placement import ( + CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven, + ) + from adcp.types.domains.formats.canonical.video_hosted import CanonicalFormatHostedVideo + from adcp.types.domains.formats.canonical.video_vast import CanonicalFormatVastVideo + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "formats.canonical._base": ( + "CanonicalFormatBase", + "CompositionModel", + "ReferenceMutability", + "RequiredPixelRatio", + "Slot", + ), + "formats.canonical.agent_placement": ( + "CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement", + ), + "formats.canonical.audio_daast": ("CanonicalFormatDaastAudio",), + "formats.canonical.audio_hosted": ("CanonicalFormatHostedAudio",), + "formats.canonical.audio_vast": ("CanonicalFormatVastAudio",), + "formats.canonical.coordinated_placements": ( + "AssetSource5", + "AudioCodec2", + "CanonicalFormatCoordinatedPlacements", + "Components", + "Components1", + "Components10", + "Components11", + "Components12", + "Components13", + "Components2", + "Components3", + "Components4", + "Components5", + "Components6", + "Components7", + "Components8", + "Components9", + "ImageFormat1", + "Params", + "Params10", + "Params11", + "Params12", + "Params13", + "Params2", + "Params3", + "Params4", + "Params5", + "Params6", + "Params7", + "Params8", + "Params9", + "ServingPolicy", + "SharedSlot", + "TransitionMode3", + "Transitions1", + "Transitions2", + "Transitions3", + "Transitions4", + "Transitions5", + ), + "formats.canonical.display_tag": ("CanonicalFormatDisplayTag",), + "formats.canonical.html5": ("CanonicalFormatHtml5Banner",), + "formats.canonical.image": ("CanonicalFormatImage",), + "formats.canonical.image_carousel": ("CanonicalFormatImageCarousel",), + "formats.canonical.native_in_feed": ("CanonicalFormatNativeInFeed",), + "formats.canonical.responsive_creative": ("CanonicalFormatResponsiveCreative",), + "formats.canonical.seller_rendered_stateful_display": ( + "CanonicalFormatSellerRenderedStatefulDisplay", + "TransitionMode9", + "Transitions10", + "Transitions11", + "Transitions7", + "Transitions8", + "Transitions9", + ), + "formats.canonical.sponsored_placement": ( + "CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven", + ), + "formats.canonical.video_hosted": ("CanonicalFormatHostedVideo",), + "formats.canonical.video_vast": ("CanonicalFormatVastVideo",), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/formats/canonical/_base.py b/src/adcp/types/domains/formats/canonical/_base.py index 11adf76c7..842e53f4b 100644 --- a/src/adcp/types/domains/formats/canonical/_base.py +++ b/src/adcp/types/domains/formats/canonical/_base.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: formats/canonical/_base.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,13 +8,19 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarFloat -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ...core import downstream_connection_requirement, platform_extension_ref from ...enums import logo_slot +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarFloat + + +from pydantic_core import PydanticCustomError + + class CompositionModel(StrEnum): deterministic = 'deterministic' algorithmic = 'algorithmic' @@ -222,3 +228,21 @@ class CanonicalFormatBase(AdCPBaseModel): ge=0, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatBase: + # Compiled from formats/canonical/_base.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'macro_resolution_capabilities')) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatBase violates a constraint its schema states outside its properties (formats/canonical/_base.json, root conjunct 0) over macro_resolution_capabilities', + {'fields': 'macro_resolution_capabilities'}, + ) + return self diff --git a/src/adcp/types/domains/formats/canonical/audio_daast.py b/src/adcp/types/domains/formats/canonical/audio_daast.py index 992d7d5e3..f14c3fcf0 100644 --- a/src/adcp/types/domains/formats/canonical/audio_daast.py +++ b/src/adcp/types/domains/formats/canonical/audio_daast.py @@ -1,21 +1,26 @@ # generated by datamodel-codegen: # filename: formats/canonical/audio_daast.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarInt - from typing import Annotated, Any from adcp.types.base import SchemaInt -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ...core import daast_tracker_constraints from ...enums import daast_version as daast_version_1 from ._base import CanonicalFormatBase +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarInt + + +from pydantic_core import PydanticCustomError + + class DurationMsRangeItem(ScalarInt): __slots__ = () _constraints = {'ge': 0} @@ -66,3 +71,21 @@ class CanonicalFormatDaastAudio(CanonicalFormatBase): max_wrapper_depth: Annotated[SchemaInt | None, Field(ge=0)] = None ssl_required: StrictBool | None = None companion_image_required: StrictBool | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatDaastAudio: + # Compiled from formats/canonical/audio_daast.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'daast_version') and _c.sent(self, 'daast_versions')) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatDaastAudio violates a constraint its schema states outside its properties (formats/canonical/audio_daast.json, root conjunct 0) over daast_version, daast_versions', + {'fields': 'daast_version, daast_versions'}, + ) + return self diff --git a/src/adcp/types/domains/formats/canonical/audio_vast.py b/src/adcp/types/domains/formats/canonical/audio_vast.py index 841bbe664..17ec28128 100644 --- a/src/adcp/types/domains/formats/canonical/audio_vast.py +++ b/src/adcp/types/domains/formats/canonical/audio_vast.py @@ -1,21 +1,26 @@ # generated by datamodel-codegen: # filename: formats/canonical/audio_vast.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarInt - from typing import Annotated, Any from adcp.types.base import SchemaInt -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ...core import vast_media_file_requirements from ...enums import vast_version as vast_version_1 from ._base import CanonicalFormatBase +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarInt + + +from pydantic_core import PydanticCustomError + + class DurationMsRangeItem(ScalarInt): __slots__ = () _constraints = {'ge': 0} @@ -81,3 +86,21 @@ class CanonicalFormatVastAudio(CanonicalFormatBase): StrictBool | None, Field(description='Whether the resolved VAST audio ad must include a companion creative.'), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatVastAudio: + # Compiled from formats/canonical/audio_vast.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not ((_c.sent(self, 'vast_version') and _c.sent(self, 'vast_versions')) or (_c.descend(self, 'media_file_requirements', lambda media_file_requirements: _c.descend(media_file_requirements, 'mime_types', lambda mime_types: _c.some(mime_types, lambda item: not (_c.matches(item, '^[Aa][Uu][Dd][Ii][Oo]/')))) and _c.sent(media_file_requirements, 'mime_types')) and _c.sent(self, 'media_file_requirements')) or (_c.descend(self, 'media_file_requirements', lambda media_file_requirements: _c.sent(media_file_requirements, 'min_width') or _c.sent(media_file_requirements, 'max_width') or _c.sent(media_file_requirements, 'min_height') or _c.sent(media_file_requirements, 'max_height')) and _c.sent(self, 'media_file_requirements'))) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatVastAudio violates a constraint its schema states outside its properties (formats/canonical/audio_vast.json, root conjunct 0) over max_height, max_width, media_file_requirements, mime_types, min_height, min_width, vast_version, vast_versions', + {'fields': 'max_height, max_width, media_file_requirements, mime_types, min_height, min_width, vast_version, vast_versions'}, + ) + return self diff --git a/src/adcp/types/domains/formats/canonical/display_tag.py b/src/adcp/types/domains/formats/canonical/display_tag.py index cd4079799..905713480 100644 --- a/src/adcp/types/domains/formats/canonical/display_tag.py +++ b/src/adcp/types/domains/formats/canonical/display_tag.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: formats/canonical/display_tag.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ._base import CanonicalFormatBase +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Size(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -132,3 +138,21 @@ class CanonicalFormatDisplayTag(CanonicalFormatBase): description="Whether the buyer's tag must integrate IAB Open Measurement SDK for viewability." ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatDisplayTag: + # Compiled from formats/canonical/display_tag.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + [not (_c.sent(self, 'sizes') or _c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height')) and (_c.sent(self, 'width') and _c.sent(self, 'height')), not (_c.sent(self, 'width') or _c.sent(self, 'height') or _c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height')) and _c.sent(self, 'sizes'), (_c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height')) and not (_c.sent(self, 'width') or _c.sent(self, 'height') or _c.sent(self, 'sizes')), not (_c.sent(self, 'width') or _c.sent(self, 'height') or _c.sent(self, 'sizes') or _c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height'))].count(True) == 1 + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatDisplayTag violates a constraint its schema states outside its properties (formats/canonical/display_tag.json, root conjunct 0) over height, max_height, max_width, min_height, min_width, sizes, width', + {'fields': 'height, max_height, max_width, min_height, min_width, sizes, width'}, + ) + return self diff --git a/src/adcp/types/domains/formats/canonical/html5.py b/src/adcp/types/domains/formats/canonical/html5.py index 055fb170c..d36228de1 100644 --- a/src/adcp/types/domains/formats/canonical/html5.py +++ b/src/adcp/types/domains/formats/canonical/html5.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: formats/canonical/html5.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,11 +8,17 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ._base import CanonicalFormatBase +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Size(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -150,3 +156,21 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): SchemaInt | None, Field(description='Maximum backup image file size in kilobytes.', ge=1) ] = None ssl_required: StrictBool | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatHtml5Banner: + # Compiled from formats/canonical/html5.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + [not (_c.sent(self, 'sizes') or _c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height')) and (_c.sent(self, 'width') and _c.sent(self, 'height')), not (_c.sent(self, 'width') or _c.sent(self, 'height') or _c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height')) and _c.sent(self, 'sizes'), (_c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height')) and not (_c.sent(self, 'width') or _c.sent(self, 'height') or _c.sent(self, 'sizes')), not (_c.sent(self, 'width') or _c.sent(self, 'height') or _c.sent(self, 'sizes') or _c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height'))].count(True) == 1 + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatHtml5Banner violates a constraint its schema states outside its properties (formats/canonical/html5.json, root conjunct 0) over height, max_height, max_width, min_height, min_width, sizes, width', + {'fields': 'height, max_height, max_width, min_height, min_width, sizes, width'}, + ) + return self diff --git a/src/adcp/types/domains/formats/canonical/image.py b/src/adcp/types/domains/formats/canonical/image.py index dc9cf22f0..862d291d3 100644 --- a/src/adcp/types/domains/formats/canonical/image.py +++ b/src/adcp/types/domains/formats/canonical/image.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: formats/canonical/image.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,14 +8,20 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel, SchemaInt -from adcp.types._scalar import ScalarFloat -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ...enums import activation_method from ...enums import ctv_ad_experience as ctv_ad_experience_1 from ._base import CanonicalFormatBase +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarFloat + + +from pydantic_core import PydanticCustomError + + class MotionLevel(StrEnum): static = 'static' limited_motion = 'limited_motion' @@ -180,3 +186,29 @@ class CanonicalFormatImage(CanonicalFormatBase): description='Viewer activation mechanisms this option offers (e.g. `qr_code` on a pause frame). Activations are engagement events, not impressions.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatImage: + # Compiled from formats/canonical/image.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + [not (_c.sent(self, 'sizes') or _c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height')) and (_c.sent(self, 'width') and _c.sent(self, 'height')), not (_c.sent(self, 'width') or _c.sent(self, 'height') or _c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height')) and _c.sent(self, 'sizes'), (_c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height')) and not (_c.sent(self, 'width') or _c.sent(self, 'height') or _c.sent(self, 'sizes')), not (_c.sent(self, 'width') or _c.sent(self, 'height') or _c.sent(self, 'sizes') or _c.sent(self, 'min_width') or _c.sent(self, 'max_width') or _c.sent(self, 'min_height') or _c.sent(self, 'max_height'))].count(True) == 1 + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatImage violates a constraint its schema states outside its properties (formats/canonical/image.json, root conjunct 0) over height, max_height, max_width, min_height, min_width, sizes, width', + {'fields': 'height, max_height, max_width, min_height, min_width, sizes, width'}, + ) + if not ( + (_c.descend(self, 'ctv_ad_experience', lambda ctv_ad_experience: ctv_ad_experience in ['pause', 'screensaver']) if _c.sent(self, 'ctv_ad_experience') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatImage violates a constraint its schema states outside its properties (formats/canonical/image.json, root conjunct 1) over ctv_ad_experience', + {'fields': 'ctv_ad_experience'}, + ) + return self diff --git a/src/adcp/types/domains/formats/canonical/native_in_feed.py b/src/adcp/types/domains/formats/canonical/native_in_feed.py index c23c5ad89..701c0d222 100644 --- a/src/adcp/types/domains/formats/canonical/native_in_feed.py +++ b/src/adcp/types/domains/formats/canonical/native_in_feed.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: formats/canonical/native_in_feed.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ...enums import activation_method from ...enums import ctv_ad_experience as ctv_ad_experience_1 @@ -16,6 +16,12 @@ from ._base import CanonicalFormatBase +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class MenuPlacement(StrEnum): tile = 'tile' headline_banner = 'headline_banner' @@ -188,3 +194,29 @@ class CanonicalFormatNativeInFeed(CanonicalFormatBase): description='Whether the product accepts buyer-uploaded native assets. When `rejected`, the buyer cannot ship pre-rendered title/image/body — they must use `build_creative`, `sync_creatives` with brief inputs, or an accepted `published_post` reference so the seller produces or resolves the native bundle.' ), ] = BuyerAssetAcceptance.accepted + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatNativeInFeed: + # Compiled from formats/canonical/native_in_feed.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'ctv_ad_experience', lambda ctv_ad_experience: ctv_ad_experience in ['menu', 'overlay']) if _c.sent(self, 'ctv_ad_experience') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatNativeInFeed violates a constraint its schema states outside its properties (formats/canonical/native_in_feed.json, root conjunct 0) over ctv_ad_experience', + {'fields': 'ctv_ad_experience'}, + ) + if not ( + ((_c.descend(self, 'ctv_ad_experience', lambda ctv_ad_experience: ctv_ad_experience == 'menu') and _c.sent(self, 'ctv_ad_experience')) if (_c.sent(self, 'menu_placement') or _c.sent(self, 'focus_behavior')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatNativeInFeed violates a constraint its schema states outside its properties (formats/canonical/native_in_feed.json, root conjunct 1) over ctv_ad_experience, focus_behavior, menu_placement', + {'fields': 'ctv_ad_experience, focus_behavior, menu_placement'}, + ) + return self diff --git a/src/adcp/types/domains/formats/canonical/sponsored_placement.py b/src/adcp/types/domains/formats/canonical/sponsored_placement.py index b3ab0970d..2d84cb136 100644 --- a/src/adcp/types/domains/formats/canonical/sponsored_placement.py +++ b/src/adcp/types/domains/formats/canonical/sponsored_placement.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: formats/canonical/sponsored_placement.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,13 +8,19 @@ from typing import Annotated, Any from adcp.types.base import SchemaInt -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ...enums import catalog_type from ...enums import ctv_ad_experience as ctv_ad_experience_1 from ._base import CanonicalFormatBase +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class FanoutMode(StrEnum): per_item = 'per_item' multi_item_in_creative = 'multi_item_in_creative' @@ -109,3 +115,21 @@ class CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven(CanonicalFormatB description='CTV experience this option serves. On `sponsored_placement`: `menu` (sponsored app/content tiles and rows whose assets derive from a catalog listing — Fire-TV-tile pattern), `squeezeback`, and `in_scene` (seller-composited brand integrations produced from the catalog/brief rather than a buyer wire creative). Asset-bundle menu heroes route to `native_in_feed`.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven: + # Compiled from formats/canonical/sponsored_placement.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'ctv_ad_experience', lambda ctv_ad_experience: ctv_ad_experience in ['menu', 'squeezeback', 'in_scene']) if _c.sent(self, 'ctv_ad_experience') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven violates a constraint its schema states outside its properties (formats/canonical/sponsored_placement.json, root conjunct 0) over ctv_ad_experience', + {'fields': 'ctv_ad_experience'}, + ) + return self diff --git a/src/adcp/types/domains/formats/canonical/video_hosted.py b/src/adcp/types/domains/formats/canonical/video_hosted.py index b5bcd0e21..441afdb04 100644 --- a/src/adcp/types/domains/formats/canonical/video_hosted.py +++ b/src/adcp/types/domains/formats/canonical/video_hosted.py @@ -1,21 +1,26 @@ # generated by datamodel-codegen: # filename: formats/canonical/video_hosted.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarInt - from adcp.types._str_enum import StrEnum from typing import Annotated, Any from adcp.types.base import SchemaInt -from pydantic import ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ...enums import ctv_ad_experience as ctv_ad_experience_1 from ._base import CanonicalFormatBase +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarInt + + +from pydantic_core import PydanticCustomError + + class Orientation(StrEnum): vertical = 'vertical' horizontal = 'horizontal' @@ -167,3 +172,21 @@ class CanonicalFormatHostedVideo(CanonicalFormatBase): description='CTV experience this option serves. On `video_hosted` only `screensaver` is valid (ambient looping video the platform plays on idle). Other experiences route per the matrix in docs/creative/ctv-experiences.mdx; linear CTV video declares no experience.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatHostedVideo: + # Compiled from formats/canonical/video_hosted.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'ctv_ad_experience', lambda ctv_ad_experience: ctv_ad_experience == 'screensaver') if _c.sent(self, 'ctv_ad_experience') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatHostedVideo violates a constraint its schema states outside its properties (formats/canonical/video_hosted.json, root conjunct 0) over ctv_ad_experience', + {'fields': 'ctv_ad_experience'}, + ) + return self diff --git a/src/adcp/types/domains/formats/canonical/video_vast.py b/src/adcp/types/domains/formats/canonical/video_vast.py index 4d746ee51..a40703d88 100644 --- a/src/adcp/types/domains/formats/canonical/video_vast.py +++ b/src/adcp/types/domains/formats/canonical/video_vast.py @@ -1,16 +1,14 @@ # generated by datamodel-codegen: # filename: formats/canonical/video_vast.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarInt - from adcp.types._str_enum import StrEnum from typing import Annotated, Any from adcp.types.base import SchemaInt -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ...core import vast_media_file_requirements from ...enums import activation_method @@ -20,6 +18,13 @@ from ._base import CanonicalFormatBase +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarInt + + +from pydantic_core import PydanticCustomError + + class Orientation(StrEnum): vertical = 'vertical' horizontal = 'horizontal' @@ -174,3 +179,37 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): SchemaInt | None, Field(description='Maximum VAST wrapper redirect depth permitted.', ge=0) ] = None ssl_required: StrictBool | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CanonicalFormatVastVideo: + # Compiled from formats/canonical/video_vast.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'vast_version') and _c.sent(self, 'vast_versions')) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatVastVideo violates a constraint its schema states outside its properties (formats/canonical/video_vast.json, root conjunct 0) over vast_version, vast_versions', + {'fields': 'vast_version, vast_versions'}, + ) + if not ( + (((_c.descend(self, 'creative_type', lambda creative_type: creative_type == 'nonlinear') and _c.descend(self, 'ctv_ad_experience', lambda ctv_ad_experience: not (ctv_ad_experience == 'menu')) and _c.descend(self, 'simid_supported', lambda simid_supported: simid_supported == False)) and _c.sent(self, 'creative_type')) if _c.sent(self, 'ctv_ad_experience') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatVastVideo violates a constraint its schema states outside its properties (formats/canonical/video_vast.json, root conjunct 1) over creative_type, ctv_ad_experience, simid_supported', + {'fields': 'creative_type, ctv_ad_experience, simid_supported'}, + ) + if not ( + (_c.descend(self, 'vpaid_enabled', lambda vpaid_enabled: vpaid_enabled == False) if (_c.descend(self, 'ctv_ad_experience', lambda ctv_ad_experience: ctv_ad_experience == 'in_scene') and _c.sent(self, 'ctv_ad_experience')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CanonicalFormatVastVideo violates a constraint its schema states outside its properties (formats/canonical/video_vast.json, root conjunct 2) over ctv_ad_experience, vpaid_enabled', + {'fields': 'ctv_ad_experience, vpaid_enabled'}, + ) + return self diff --git a/src/adcp/types/domains/governance/__init__.py b/src/adcp/types/domains/governance/__init__.py index 1c22fcdca..e65bf859b 100644 --- a/src/adcp/types/domains/governance/__init__.py +++ b/src/adcp/types/domains/governance/__init__.py @@ -9,119 +9,277 @@ it from its own schema's module, ``adcp.types.domains.governance.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.governance.accepted_governance_agents import ( - AcceptedGovernanceAgents, - AnyOf, - AnyOf1, - AnyOf2, - VerificationMode, -) -from adcp.types.domains.governance.attribute_definition import AttributeDefinition, RegulatoryBasi -from adcp.types.domains.governance.audience_constraints import AudienceConstraints -from adcp.types.domains.governance.check_governance_request import ( - AudienceDistribution, - AudienceDistribution1, - Baseline, - CheckGovernanceRequest, - CheckGovernanceRequest1, - CheckGovernanceRequest2, - CheckGovernanceRequest3, - DeliveryMetrics, - DeliveryMetrics2, - ExecutionCommitment, - Pacing, - ProposedCommitment, - RuntimeAttestation1, - Subject, - Subject4, - Subject6, - Subject8, - Subject9, -) -from adcp.types.domains.governance.check_governance_response import ( - ActionBinding, - CheckGovernanceResponse, - Condition, - RuntimeAttestationEvaluation, -) -from adcp.types.domains.governance.get_plan_audit_logs_request import GetPlanAuditLogsRequest -from adcp.types.domains.governance.get_plan_audit_logs_response import ( - ChannelAllocation, - DeliveryReportingPeriod, - DriftMetrics, - Entry, - Escalation, - EscalationRateTrend, - GetPlanAuditLogsResponse, - GovernedAction, - Statuses, - Summary, - Thresholds, - Type, -) -from adcp.types.domains.governance.policy_category_definition import ( - Facet, - PolicyCategoryDefinition, - RegulatoryFramework, -) -from adcp.types.domains.governance.policy_entry import Exemplar, Exemplars, Issuer, PolicyEntry -from adcp.types.domains.governance.policy_ref import PolicyReference -from adcp.types.domains.governance.report_plan_adjustment_request import ( - Action, - Decision, - ReportPlanAdjustmentRequest, -) -from adcp.types.domains.governance.report_plan_adjustment_response import ( - ReportPlanAdjustmentResponse, -) -from adcp.types.domains.governance.report_plan_outcome_request import ( - Package, - ReportPlanOutcomeRequest, - SellerResponse, -) -from adcp.types.domains.governance.report_plan_outcome_response import ( - OutcomeState, - ReportPlanOutcomeResponse, -) -from adcp.types.domains.governance.reported_outcome_error import ( - BoundedObject, - BoundedScalar, - BoundedScalar1, - BoundedValue, - BoundedValue1, - BoundedValueLevel2, - BoundedValueLevel21, - BoundedValueLevel3, - BoundedValueLevel31, - ClassificationSource, - Recovery, - ReportedOutcomeError, -) -from adcp.types.domains.governance.sync_plans_request import ( - Allocations, - Budget2, - BudgetLimit, - Channels, - Delegation, - Flight, - MixTargets, - Portfolio, - SyncPlansRequest, - TotalBudgetCap, -) -from adcp.types.domains.governance.sync_plans_response import ( - Category, - ResolvedPolicy, - Status51, - SyncPlansResponse, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.governance.accepted_governance_agents import ( + AcceptedGovernanceAgents, + AnyOf, + AnyOf1, + AnyOf2, + VerificationMode, + ) + from adcp.types.domains.governance.attribute_definition import ( + AttributeDefinition, + RegulatoryBasi, + ) + from adcp.types.domains.governance.audience_constraints import AudienceConstraints + from adcp.types.domains.governance.check_governance_request import ( + AudienceDistribution, + AudienceDistribution1, + Baseline, + CheckGovernanceRequest, + CheckGovernanceRequest1, + CheckGovernanceRequest2, + CheckGovernanceRequest3, + DeliveryMetrics, + DeliveryMetrics2, + ExecutionCommitment, + Pacing, + ProposedCommitment, + RuntimeAttestation1, + Subject, + Subject4, + Subject6, + Subject8, + Subject9, + ) + from adcp.types.domains.governance.check_governance_response import ( + ActionBinding, + CheckGovernanceResponse, + Condition, + RuntimeAttestationEvaluation, + ) + from adcp.types.domains.governance.get_plan_audit_logs_request import GetPlanAuditLogsRequest + from adcp.types.domains.governance.get_plan_audit_logs_response import ( + ChannelAllocation, + DeliveryReportingPeriod, + DriftMetrics, + Entry, + Escalation, + EscalationRateTrend, + GetPlanAuditLogsResponse, + GovernedAction, + Statuses, + Summary, + Thresholds, + Type, + ) + from adcp.types.domains.governance.policy_category_definition import ( + Facet, + PolicyCategoryDefinition, + RegulatoryFramework, + ) + from adcp.types.domains.governance.policy_entry import Exemplar, Exemplars, Issuer, PolicyEntry + from adcp.types.domains.governance.policy_ref import PolicyReference + from adcp.types.domains.governance.report_plan_adjustment_request import ( + Action, + Decision, + ReportPlanAdjustmentRequest, + ) + from adcp.types.domains.governance.report_plan_adjustment_response import ( + ReportPlanAdjustmentResponse, + ) + from adcp.types.domains.governance.report_plan_outcome_request import ( + Package, + ReportPlanOutcomeRequest, + SellerResponse, + ) + from adcp.types.domains.governance.report_plan_outcome_response import ( + OutcomeState, + ReportPlanOutcomeResponse, + ) + from adcp.types.domains.governance.reported_outcome_error import ( + BoundedObject, + BoundedScalar, + BoundedScalar1, + BoundedValue, + BoundedValue1, + BoundedValueLevel2, + BoundedValueLevel21, + BoundedValueLevel3, + BoundedValueLevel31, + ClassificationSource, + Recovery, + ReportedOutcomeError, + ) + from adcp.types.domains.governance.sync_plans_request import ( + Allocations, + Budget2, + BudgetLimit, + Channels, + Delegation, + Flight, + MixTargets, + Portfolio, + SyncPlansRequest, + TotalBudgetCap, + ) + from adcp.types.domains.governance.sync_plans_response import ( + Category, + ResolvedPolicy, + Status51, + SyncPlansResponse, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "governance.accepted_governance_agents": ( + "AcceptedGovernanceAgents", + "AnyOf", + "AnyOf1", + "AnyOf2", + "VerificationMode", + ), + "governance.attribute_definition": ( + "AttributeDefinition", + "RegulatoryBasi", + ), + "governance.audience_constraints": ("AudienceConstraints",), + "governance.check_governance_request": ( + "AudienceDistribution", + "AudienceDistribution1", + "Baseline", + "CheckGovernanceRequest", + "CheckGovernanceRequest1", + "CheckGovernanceRequest2", + "CheckGovernanceRequest3", + "DeliveryMetrics", + "DeliveryMetrics2", + "ExecutionCommitment", + "Pacing", + "ProposedCommitment", + "RuntimeAttestation1", + "Subject", + "Subject4", + "Subject6", + "Subject8", + "Subject9", + ), + "governance.check_governance_response": ( + "ActionBinding", + "CheckGovernanceResponse", + "Condition", + "RuntimeAttestationEvaluation", + ), + "governance.get_plan_audit_logs_request": ("GetPlanAuditLogsRequest",), + "governance.get_plan_audit_logs_response": ( + "ChannelAllocation", + "DeliveryReportingPeriod", + "DriftMetrics", + "Entry", + "Escalation", + "EscalationRateTrend", + "GetPlanAuditLogsResponse", + "GovernedAction", + "Statuses", + "Summary", + "Thresholds", + "Type", + ), + "governance.policy_category_definition": ( + "Facet", + "PolicyCategoryDefinition", + "RegulatoryFramework", + ), + "governance.policy_entry": ( + "Exemplar", + "Exemplars", + "Issuer", + "PolicyEntry", + ), + "governance.policy_ref": ("PolicyReference",), + "governance.report_plan_adjustment_request": ( + "Action", + "Decision", + "ReportPlanAdjustmentRequest", + ), + "governance.report_plan_adjustment_response": ("ReportPlanAdjustmentResponse",), + "governance.report_plan_outcome_request": ( + "Package", + "ReportPlanOutcomeRequest", + "SellerResponse", + ), + "governance.report_plan_outcome_response": ( + "OutcomeState", + "ReportPlanOutcomeResponse", + ), + "governance.reported_outcome_error": ( + "BoundedObject", + "BoundedScalar", + "BoundedScalar1", + "BoundedValue", + "BoundedValue1", + "BoundedValueLevel2", + "BoundedValueLevel21", + "BoundedValueLevel3", + "BoundedValueLevel31", + "ClassificationSource", + "Recovery", + "ReportedOutcomeError", + ), + "governance.sync_plans_request": ( + "Allocations", + "Budget2", + "BudgetLimit", + "Channels", + "Delegation", + "Flight", + "MixTargets", + "Portfolio", + "SyncPlansRequest", + "TotalBudgetCap", + ), + "governance.sync_plans_response": ( + "Category", + "ResolvedPolicy", + "Status51", + "SyncPlansResponse", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/governance/audience_constraints.py b/src/adcp/types/domains/governance/audience_constraints.py index 7364e4f4e..482df977b 100644 --- a/src/adcp/types/domains/governance/audience_constraints.py +++ b/src/adcp/types/domains/governance/audience_constraints.py @@ -1,17 +1,23 @@ # generated by datamodel-codegen: # filename: governance/audience_constraints.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import audience_selector +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class AudienceConstraints(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -30,3 +36,20 @@ class AudienceConstraints(AdCPBaseModel): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AudienceConstraints: + # Compiled from governance/audience-constraints.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'AudienceConstraints violates a constraint its schema states outside its properties (governance/audience-constraints.json, root conjunct 0)', + ) + return self diff --git a/src/adcp/types/domains/governance/check_governance_request.py b/src/adcp/types/domains/governance/check_governance_request.py index e5ad75148..d04427e71 100644 --- a/src/adcp/types/domains/governance/check_governance_request.py +++ b/src/adcp/types/domains/governance/check_governance_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/check_governance_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Annotated, Any, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat, StringConstraints +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat, StringConstraints, model_validator from ..core import brand_ref, business_entity, canonical_proposal from ..core import context as context_1 @@ -22,6 +22,12 @@ from ..enums import purchase_type as purchase_type_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class CheckGovernanceRequest1(AdCPBaseModel): pass @@ -599,3 +605,142 @@ class CheckGovernanceRequest3(CheckGovernanceRequest1, CheckGovernanceRequest2): class CheckGovernanceRequest(AdcpRequest, CheckGovernanceRequest3): 'Universal governance check for campaign actions. The governance agent infers the check type from the fields present: tool+payload = intent check (proposed, orchestrator-side); planned_delivery or delivery_metrics with governance_context = execution or lifecycle check (committed, service-side). Proposal acceptance supplies the immutable proposal separately so governance can inspect its typed commercial terms while payload remains the exact downstream arguments. MediaBuy controls use buyer-proposed and seller-computed positive-delta ceilings. The first check is addressed by plan_id. Subsequent service-side checks use the opaque governance_context as the authoritative plan binding.' + @model_validator(mode='after') + def _require_schema_required_group(self) -> CheckGovernanceRequest: + # Compiled from governance/check-governance-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.sent(self, 'plan_id') + or (_c.sent(self, 'governance_context') and (_c.sent(self, 'planned_delivery') or _c.sent(self, 'delivery_metrics'))) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 0) over delivery_metrics, governance_context, plan_id, planned_delivery', + {'fields': 'delivery_metrics, governance_context, plan_id, planned_delivery'}, + ) + if not ( + ((not (_c.sent(self, 'governance_context')) and (_c.sent(self, 'plan_id') and _c.sent(self, 'payload') and _c.sent(self, 'target_agent'))) if _c.sent(self, 'tool') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 1) over governance_context, payload, plan_id, target_agent, tool', + {'fields': 'governance_context, payload, plan_id, target_agent, tool'}, + ) + if not ( + ((not (_c.sent(self, 'governance_context')) and (_c.sent(self, 'plan_id') and _c.sent(self, 'tool') and _c.sent(self, 'target_agent'))) if _c.sent(self, 'payload') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 2) over governance_context, payload, plan_id, target_agent, tool', + {'fields': 'governance_context, payload, plan_id, target_agent, tool'}, + ) + if not ( + ((not (_c.sent(self, 'governance_context') or _c.sent(self, 'planned_delivery') or _c.sent(self, 'delivery_metrics')) and (_c.sent(self, 'plan_id') and _c.sent(self, 'tool') and _c.sent(self, 'payload'))) if _c.sent(self, 'consultation_context') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 3) over consultation_context, delivery_metrics, governance_context, payload, plan_id, planned_delivery, tool', + {'fields': 'consultation_context, delivery_metrics, governance_context, payload, plan_id, planned_delivery, tool'}, + ) + if not ( + ((not (_c.sent(self, 'governance_context') or _c.sent(self, 'planned_delivery') or _c.sent(self, 'delivery_metrics')) and (_c.sent(self, 'plan_id') and _c.sent(self, 'tool') and _c.sent(self, 'payload'))) if _c.sent(self, 'proposed_commitment') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 4) over delivery_metrics, governance_context, payload, plan_id, planned_delivery, proposed_commitment, tool', + {'fields': 'delivery_metrics, governance_context, payload, plan_id, planned_delivery, proposed_commitment, tool'}, + ) + if not ( + ((_c.descend(self, 'tool', lambda tool: tool == 'accept_proposal') and (_c.sent(self, 'plan_id') and _c.sent(self, 'tool') and _c.sent(self, 'payload') and _c.sent(self, 'target_agent'))) if _c.sent(self, 'proposal') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 5) over payload, plan_id, proposal, target_agent, tool', + {'fields': 'payload, plan_id, proposal, target_agent, tool'}, + ) + if not ( + ((not (_c.sent(self, 'tool') or _c.sent(self, 'payload') or _c.sent(self, 'proposed_commitment')) and (_c.sent(self, 'governance_context') and _c.sent(self, 'planned_delivery'))) if _c.sent(self, 'execution_commitment') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 6) over execution_commitment, governance_context, payload, planned_delivery, proposed_commitment, tool', + {'fields': 'execution_commitment, governance_context, payload, planned_delivery, proposed_commitment, tool'}, + ) + if not ( + (_c.sent(self, 'proposed_commitment') if (_c.descend(self, 'tool', lambda tool: tool in ['update_media_buy', 'buy_products', 'accept_proposal', 'control_media_buy', 'acquire_rights', 'update_rights', 'activate_signal', 'build_creative']) and (_c.sent(self, 'tool') and _c.sent(self, 'payload'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 7) over payload, proposed_commitment, tool', + {'fields': 'payload, proposed_commitment, tool'}, + ) + if not ( + (_c.sent(self, 'proposal') if (_c.descend(self, 'tool', lambda tool: tool == 'accept_proposal') and (_c.sent(self, 'tool') and _c.sent(self, 'payload'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 8) over payload, proposal, tool', + {'fields': 'payload, proposal, tool'}, + ) + if not ( + (_c.sent(self, 'governance_context') if (_c.sent(self, 'planned_delivery') or _c.sent(self, 'delivery_metrics')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 9) over delivery_metrics, governance_context, planned_delivery', + {'fields': 'delivery_metrics, governance_context, planned_delivery'}, + ) + if not ( + (_c.descend(self, 'planned_delivery', lambda planned_delivery: _c.sent(planned_delivery, 'currency')) if (_c.descend(self, 'planned_delivery', lambda planned_delivery: _c.sent(planned_delivery, 'total_budget')) and _c.sent(self, 'planned_delivery')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 10) over currency, planned_delivery, total_budget', + {'fields': 'currency, planned_delivery, total_budget'}, + ) + if not ( + ((_c.sent(self, 'governance_context') and _c.sent(self, 'planned_delivery')) if _c.sent(self, 'delivery_metrics') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 11) over delivery_metrics, governance_context, planned_delivery', + {'fields': 'delivery_metrics, governance_context, planned_delivery'}, + ) + if not ( + (_c.descend(self, 'planned_delivery', lambda planned_delivery: _c.sent(planned_delivery, 'media_buy_id')) if (_c.sent(self, 'planned_delivery') and ((_c.descend(self, 'phase', lambda phase: phase in ['modification', 'delivery']) and _c.sent(self, 'phase')) or (_c.descend(self, 'governance_phase', lambda governance_phase: governance_phase in ['modification', 'delivery']) and _c.supplied(self, 'governance_phase')))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 12) over governance_phase, media_buy_id, phase, planned_delivery', + {'fields': 'governance_phase, media_buy_id, phase, planned_delivery'}, + ) + if not ( + ((_c.descend(self, 'delivery_metrics', lambda delivery_metrics: _c.sent(delivery_metrics, 'statement_id') and _c.sent(delivery_metrics, 'statement_digest') and _c.sent(delivery_metrics, 'sequence') and _c.sent(delivery_metrics, 'issued_at') and _c.sent(delivery_metrics, 'reporting_period') and _c.sent(delivery_metrics, 'cumulative_spend') and _c.sent(delivery_metrics, 'currency')) and (_c.sent(self, 'governance_context') and _c.sent(self, 'planned_delivery') and _c.sent(self, 'delivery_metrics'))) if ((_c.descend(self, 'phase', lambda phase: phase == 'delivery') and _c.sent(self, 'phase')) or (_c.descend(self, 'governance_phase', lambda governance_phase: governance_phase == 'delivery') and _c.supplied(self, 'governance_phase'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 13) over cumulative_spend, currency, delivery_metrics, governance_context, governance_phase, issued_at, phase, planned_delivery, reporting_period, sequence, statement_digest, statement_id', + {'fields': 'cumulative_spend, currency, delivery_metrics, governance_context, governance_phase, issued_at, phase, planned_delivery, reporting_period, sequence, statement_digest, statement_id'}, + ) + if not ( + not ((_c.sent(self, 'tool') or _c.sent(self, 'payload')) and (_c.sent(self, 'planned_delivery') or _c.sent(self, 'delivery_metrics'))) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 14) over delivery_metrics, payload, planned_delivery, tool', + {'fields': 'delivery_metrics, payload, planned_delivery, tool'}, + ) + if not ( + (((_c.descend(self, 'payload', lambda payload: _c.descend(payload, 'action', lambda action: action == 'activate' and _c.json_type(action, 'string'))) and _c.descend(self, 'purchase_type', lambda purchase_type: purchase_type == 'signal_activation' and _c.json_type(purchase_type, 'string')) and _c.descend(self, 'tool', lambda tool: tool == 'activate_signal')) and (_c.sent(self, 'purchase_type') and _c.sent(self, 'tool') and _c.sent(self, 'payload'))) if _c.sent(self, 'runtime_attestations') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceRequest violates a constraint its schema states outside its properties (governance/check-governance-request.json, root conjunct 15) over action, payload, purchase_type, runtime_attestations, tool', + {'fields': 'action, payload, purchase_type, runtime_attestations, tool'}, + ) + return self diff --git a/src/adcp/types/domains/governance/check_governance_response.py b/src/adcp/types/domains/governance/check_governance_response.py index 95abdfd38..65c1c4b9f 100644 --- a/src/adcp/types/domains/governance/check_governance_response.py +++ b/src/adcp/types/domains/governance/check_governance_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/check_governance_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -19,6 +19,12 @@ from ..enums import escalation_severity, governance_decision, governance_mode +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class CheckType(StrEnum): intent = 'intent' execution = 'execution' @@ -287,3 +293,29 @@ def _status_to_verdict(cls, data: Any) -> Any: ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> CheckGovernanceResponse: + # Compiled from governance/check-governance-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((((not (_c.sent(self, 'governance_context') or _c.sent(self, 'expires_at')) and (_c.descend(self, 'check_type', lambda check_type: check_type == 'intent') and _c.descend(self, 'conditions', lambda conditions: _c.length(conditions, minimum=1))) and (_c.sent(self, 'conditions') and _c.sent(self, 'consultation_context'))) if (_c.descend(self, 'verdict', lambda verdict: verdict == 'conditions') and _c.sent(self, 'verdict')) else not (_c.sent(self, 'conditions') or _c.sent(self, 'consultation_context'))) and ((_c.descend(self, 'findings', lambda findings: _c.length(findings, minimum=1)) and _c.sent(self, 'findings')) if (_c.descend(self, 'verdict', lambda verdict: verdict == 'denied') and _c.sent(self, 'verdict')) else True) and ((_c.sent(self, 'expires_at') and _c.sent(self, 'governance_context')) if (_c.descend(self, 'verdict', lambda verdict: verdict == 'approved') and _c.sent(self, 'verdict')) else not (_c.sent(self, 'governance_context') or _c.sent(self, 'expires_at')))) if _c.sent(self, 'check_type') else (_c.sent(self, 'plan_id') and ((_c.descend(self, 'conditions', lambda conditions: _c.length(conditions, minimum=1)) and _c.sent(self, 'conditions')) if (_c.descend(self, 'verdict', lambda verdict: verdict == 'conditions') and _c.sent(self, 'verdict')) else True) and ((_c.descend(self, 'findings', lambda findings: _c.length(findings, minimum=1)) and _c.sent(self, 'findings')) if (_c.descend(self, 'verdict', lambda verdict: verdict == 'denied') and _c.sent(self, 'verdict')) else True) and (_c.sent(self, 'expires_at') if (_c.descend(self, 'verdict', lambda verdict: verdict in ['approved', 'conditions']) and _c.sent(self, 'verdict')) else True))) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceResponse violates a constraint its schema states outside its properties (governance/check-governance-response.json, root conjunct 0) over check_type, conditions, consultation_context, expires_at, findings, governance_context, plan_id, verdict', + {'fields': 'check_type, conditions, consultation_context, expires_at, findings, governance_context, plan_id, verdict'}, + ) + if not ( + (_c.sent(self, 'runtime_attestation_binding_digest') if _c.sent(self, 'runtime_attestation_evaluations') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CheckGovernanceResponse violates a constraint its schema states outside its properties (governance/check-governance-response.json, root conjunct 1) over runtime_attestation_binding_digest, runtime_attestation_evaluations', + {'fields': 'runtime_attestation_binding_digest, runtime_attestation_evaluations'}, + ) + return self diff --git a/src/adcp/types/domains/governance/get_plan_audit_logs_request.py b/src/adcp/types/domains/governance/get_plan_audit_logs_request.py index c64043c8a..ec81ef72d 100644 --- a/src/adcp/types/domains/governance/get_plan_audit_logs_request.py +++ b/src/adcp/types/domains/governance/get_plan_audit_logs_request.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: governance/get_plan_audit_logs_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest -from typing import Annotated +from typing import Annotated, ClassVar from pydantic import ConfigDict, Field, StrictBool, model_validator @@ -16,6 +16,9 @@ from ..enums import purchase_type +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -56,19 +59,33 @@ class GetPlanAuditLogsRequest(AdcpRequest, AdcpVersionEnvelope): ] = False context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'governance_contexts', 'plan_ids', 'portfolio_plan_ids'}) @model_validator(mode='after') def _require_schema_required_group(self) -> GetPlanAuditLogsRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('plan_ids',), ('portfolio_plan_ids',), ('governance_contexts',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'GetPlanAuditLogsRequest requires at least one of these field groups: plan_ids | portfolio_plan_ids | governance_contexts', - ) + # Compiled from governance/get-plan-audit-logs-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # plan_ids + ( + _c.sent(self, 'plan_ids') + ) + # portfolio_plan_ids + or ( + _c.sent(self, 'portfolio_plan_ids') + ) + # governance_contexts + or ( + _c.sent(self, 'governance_contexts') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'GetPlanAuditLogsRequest requires at least one of these field groups: plan_ids | portfolio_plan_ids | governance_contexts', + ) + return self diff --git a/src/adcp/types/domains/governance/report_plan_adjustment_request.py b/src/adcp/types/domains/governance/report_plan_adjustment_request.py index fcb5eeb10..eb1d9c0b3 100644 --- a/src/adcp/types/domains/governance/report_plan_adjustment_request.py +++ b/src/adcp/types/domains/governance/report_plan_adjustment_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/report_plan_adjustment_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,13 +10,19 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..core import context as context_1 from ..core import ext as ext_1 from ..core.version_envelope import AdcpVersionEnvelope +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Action(StrEnum): report = 'report' review = 'review' @@ -158,3 +164,69 @@ class ReportPlanAdjustmentRequest(AdcpRequest, AdcpVersionEnvelope): ] context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportPlanAdjustmentRequest: + # Compiled from governance/report-plan-adjustment-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'outcome_id') and _c.sent(self, 'seller_reference') and _c.sent(self, 'seller_adjustment_id') and _c.sent(self, 'adjustment_type') and _c.sent(self, 'amount') and _c.sent(self, 'reason') and _c.sent(self, 'effective_at') and _c.sent(self, 'evidence')) if (_c.descend(self, 'action', lambda action: action == 'report') and _c.sent(self, 'action')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportPlanAdjustmentRequest violates a constraint its schema states outside its properties (governance/report-plan-adjustment-request.json, root conjunct 0) over action, adjustment_type, amount, effective_at, evidence, outcome_id, reason, seller_adjustment_id, seller_reference', + {'fields': 'action, adjustment_type, amount, effective_at, evidence, outcome_id, reason, seller_adjustment_id, seller_reference'}, + ) + if not ( + ((_c.sent(self, 'adjustment_id') and _c.sent(self, 'decision')) if (_c.descend(self, 'action', lambda action: action == 'review') and _c.sent(self, 'action')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportPlanAdjustmentRequest violates a constraint its schema states outside its properties (governance/report-plan-adjustment-request.json, root conjunct 1) over action, adjustment_id, decision', + {'fields': 'action, adjustment_id, decision'}, + ) + if not ( + (_c.sent(self, 'reason') if ((_c.descend(self, 'action', lambda action: action == 'review') and _c.descend(self, 'decision', lambda decision: decision == 'dispute')) and (_c.sent(self, 'action') and _c.sent(self, 'decision'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportPlanAdjustmentRequest violates a constraint its schema states outside its properties (governance/report-plan-adjustment-request.json, root conjunct 2) over action, decision, reason', + {'fields': 'action, decision, reason'}, + ) + if not ( + (_c.descend(self, 'evidence', lambda evidence: _c.descend(evidence, 'evidence_type', lambda evidence_type: evidence_type == 'decommitment_agreement')) if (_c.descend(self, 'adjustment_type', lambda adjustment_type: adjustment_type == 'decommitment') and _c.sent(self, 'adjustment_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportPlanAdjustmentRequest violates a constraint its schema states outside its properties (governance/report-plan-adjustment-request.json, root conjunct 3) over adjustment_type, evidence, evidence_type', + {'fields': 'adjustment_type, evidence, evidence_type'}, + ) + if not ( + (_c.descend(self, 'evidence', lambda evidence: _c.descend(evidence, 'evidence_type', lambda evidence_type: evidence_type == 'refund_settlement')) if (_c.descend(self, 'adjustment_type', lambda adjustment_type: adjustment_type == 'refund') and _c.sent(self, 'adjustment_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportPlanAdjustmentRequest violates a constraint its schema states outside its properties (governance/report-plan-adjustment-request.json, root conjunct 4) over adjustment_type, evidence, evidence_type', + {'fields': 'adjustment_type, evidence, evidence_type'}, + ) + if not ( + (_c.descend(self, 'evidence', lambda evidence: _c.descend(evidence, 'evidence_type', lambda evidence_type: evidence_type == 'credit_note')) if (_c.descend(self, 'adjustment_type', lambda adjustment_type: adjustment_type == 'credit') and _c.sent(self, 'adjustment_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportPlanAdjustmentRequest violates a constraint its schema states outside its properties (governance/report-plan-adjustment-request.json, root conjunct 5) over adjustment_type, evidence, evidence_type', + {'fields': 'adjustment_type, evidence, evidence_type'}, + ) + if not ( + (_c.descend(self, 'evidence', lambda evidence: _c.descend(evidence, 'evidence_type', lambda evidence_type: evidence_type == 'makegood_agreement')) if (_c.descend(self, 'adjustment_type', lambda adjustment_type: adjustment_type == 'makegood') and _c.sent(self, 'adjustment_type')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportPlanAdjustmentRequest violates a constraint its schema states outside its properties (governance/report-plan-adjustment-request.json, root conjunct 6) over adjustment_type, evidence, evidence_type', + {'fields': 'adjustment_type, evidence, evidence_type'}, + ) + return self diff --git a/src/adcp/types/domains/governance/report_plan_outcome_request.py b/src/adcp/types/domains/governance/report_plan_outcome_request.py index 9ce76b49d..1a9fd88b6 100644 --- a/src/adcp/types/domains/governance/report_plan_outcome_request.py +++ b/src/adcp/types/domains/governance/report_plan_outcome_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/report_plan_outcome_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import context as context_1 from ..core import ext as ext_1 @@ -21,6 +21,12 @@ from . import reported_outcome_error +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Package(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -182,3 +188,37 @@ class ReportPlanOutcomeRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportPlanOutcomeRequest: + # Compiled from governance/report-plan-outcome-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.sent(self, 'check_id') and _c.sent(self, 'governance_context') and _c.sent(self, 'seller_response')) if (_c.descend(self, 'outcome', lambda outcome: outcome == 'completed') and _c.sent(self, 'outcome')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportPlanOutcomeRequest violates a constraint its schema states outside its properties (governance/report-plan-outcome-request.json, root conjunct 0) over check_id, governance_context, outcome, seller_response', + {'fields': 'check_id, governance_context, outcome, seller_response'}, + ) + if not ( + ((_c.sent(self, 'check_id') and _c.sent(self, 'governance_context') and _c.sent(self, 'error')) if (_c.descend(self, 'outcome', lambda outcome: outcome == 'failed') and _c.sent(self, 'outcome')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportPlanOutcomeRequest violates a constraint its schema states outside its properties (governance/report-plan-outcome-request.json, root conjunct 1) over check_id, error, governance_context, outcome', + {'fields': 'check_id, error, governance_context, outcome'}, + ) + if not ( + ((_c.sent(self, 'check_id') and _c.sent(self, 'governance_context') and _c.sent(self, 'delivery')) if (_c.descend(self, 'outcome', lambda outcome: outcome == 'delivery') and _c.sent(self, 'outcome')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ReportPlanOutcomeRequest violates a constraint its schema states outside its properties (governance/report-plan-outcome-request.json, root conjunct 2) over check_id, delivery, governance_context, outcome', + {'fields': 'check_id, delivery, governance_context, outcome'}, + ) + return self diff --git a/src/adcp/types/domains/governance/reported_outcome_error.py b/src/adcp/types/domains/governance/reported_outcome_error.py index 38574a17f..3fb8e88e8 100644 --- a/src/adcp/types/domains/governance/reported_outcome_error.py +++ b/src/adcp/types/domains/governance/reported_outcome_error.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/reported_outcome_error.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,8 +8,14 @@ from typing import Any, Annotated, Dict from adcp.types.base import AdCPBaseModel +from pydantic import ConfigDict, Field, RootModel, StrictBool, StrictFloat, StringConstraints, model_validator + + +from adcp.types import _conditions as _c from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, RootModel, StrictBool, StrictFloat, StringConstraints + + +from pydantic_core import PydanticCustomError class Recovery(StrEnum): @@ -95,3 +101,21 @@ class ReportedOutcomeError(AdCPBaseModel): BoundedObject | None, Field(description='Bounded extension envelope for forward-compatible seller fields.'), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ReportedOutcomeError: + # Compiled from governance/reported-outcome-error.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.members(self, maximum=16) + and _c.keys(self, lambda name: _c.length(name, maximum=128)) + ): + raise PydanticCustomError( + 'allOf', + 'ReportedOutcomeError violates a constraint its schema states outside its properties (governance/reported-outcome-error.json, root conjunct 0)', + ) + return self diff --git a/src/adcp/types/domains/media_buy/__init__.py b/src/adcp/types/domains/media_buy/__init__.py index 1b71063f6..011a2ec0e 100644 --- a/src/adcp/types/domains/media_buy/__init__.py +++ b/src/adcp/types/domains/media_buy/__init__.py @@ -9,528 +9,1076 @@ it from its own schema's module, ``adcp.types.domains.media_buy.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.media_buy.accept_proposal_async_response_input_required import ( - AcceptProposalInputRequired, -) -from adcp.types.domains.media_buy.accept_proposal_async_response_submitted import ( - AcceptProposalSubmitted, -) -from adcp.types.domains.media_buy.accept_proposal_async_response_working import ( - AcceptProposalWorking, -) -from adcp.types.domains.media_buy.accept_proposal_request import AcceptProposalRequest -from adcp.types.domains.media_buy.accept_proposal_response import ( - AcceptProposalResponse, - AcceptProposalResponse1, - AcceptProposalResponse2, - AcceptProposalResponse3, - AcceptProposalResponse4, - AcceptProposalResponse5, - AcceptProposalResponse6, - AcceptProposalResponse7, -) -from adcp.types.domains.media_buy.acceptance_context import ( - AcceptanceContext, - AdvertiserJurisdiction, - DeliveryJurisdiction, - Subject, -) -from adcp.types.domains.media_buy.acceptance_policy_catalog import AcceptancePolicyCatalog -from adcp.types.domains.media_buy.acceptance_policy_profile import ( - AcceptancePolicyProfile, - Coverage, - PolicyRef, - RegionAliase, - SubjectCategory, -) -from adcp.types.domains.media_buy.acceptance_policy_profile_ref import ( - RegistryAcceptancePolicyProfileReference, -) -from adcp.types.domains.media_buy.acceptance_policy_requirement import ( - AcceptancePolicyRequirement, - AcceptancePolicyRequirement1, - AcceptancePolicyRequirement10, - AcceptancePolicyRequirement11, - AcceptancePolicyRequirement12, - AcceptancePolicyRequirement13, - AcceptancePolicyRequirement14, - AcceptancePolicyRequirement15, - AcceptancePolicyRequirement16, - AcceptancePolicyRequirement17, - AcceptancePolicyRequirement2, - AcceptancePolicyRequirement3, - AcceptancePolicyRequirement4, - AcceptancePolicyRequirement5, - AcceptancePolicyRequirement6, - AcceptancePolicyRequirement7, - AcceptancePolicyRequirement8, - AcceptancePolicyRequirement9, - Criterion, - FormatId, -) -from adcp.types.domains.media_buy.acceptance_policy_rule import AcceptancePolicyRule, Disposition -from adcp.types.domains.media_buy.build_creative_async_response_input_required import ( - BuildCreativeInputRequired, -) -from adcp.types.domains.media_buy.build_creative_async_response_submitted import ( - BuildCreativeSubmitted, -) -from adcp.types.domains.media_buy.build_creative_async_response_working import BuildCreativeWorking -from adcp.types.domains.media_buy.build_creative_request import ( - BuildCreativeRequest, - Dimension, - KeepMode, - MaxSpend, - Mode, - PreviewInput, - SignalCondition, - SignalCondition1, - SignalCondition2, - SignalCondition3, - SignalCondition4, - SignalCondition5, - SignalCondition6, - SignalCondition7, - TargetCapabilityId, - VariantAxis, -) -from adcp.types.domains.media_buy.build_creative_response import ( - BuildCreativeResponse, - BuildCreativeResponse1, - BuildCreativeResponse2, - BuildCreativeResponse3, - BuildCreativeResponse4, - BuildCreativeResponse5, - BuildCreativeResponse6, - CatalogItemRef, - Estimate, - Eval, - Input, - Input2, - PerLeaf, - Preview, - Preview2, - Preview3, - Preview4, - Variant, -) -from adcp.types.domains.media_buy.buy_products_async_response_input_required import ( - BuyProductsInputRequired, -) -from adcp.types.domains.media_buy.buy_products_async_response_submitted import BuyProductsSubmitted -from adcp.types.domains.media_buy.buy_products_async_response_working import BuyProductsWorking -from adcp.types.domains.media_buy.buy_products_request import BuyProductsRequest -from adcp.types.domains.media_buy.buy_products_response import ( - BuyProductsResponse, - BuyProductsResponse1, - BuyProductsResponse2, - BuyProductsResponse3, - BuyProductsResponse4, - BuyProductsResponse5, - BuyProductsResponse6, - BuyProductsResponse7, -) -from adcp.types.domains.media_buy.change_term import AllowedStatus, Condition, MediaBuyChangeTerm -from adcp.types.domains.media_buy.change_term_constraints import ( - MediaBuyChangeTermConstraints, - MediaBuyChangeTermConstraints1, - MediaBuyChangeTermConstraints2, - MediaBuyChangeTermConstraints3, - MediaBuyChangeTermConstraints4, - Money, -) -from adcp.types.domains.media_buy.commercial_terms import ( - CancellationTerms, - CommercialTerms, - Fee, - ReportingCommitment, -) -from adcp.types.domains.media_buy.control_media_buy_async_response_input_required import ( - ControlMediaBuyInputRequired, -) -from adcp.types.domains.media_buy.control_media_buy_async_response_submitted import ( - ControlMediaBuySubmitted, -) -from adcp.types.domains.media_buy.control_media_buy_async_response_working import ( - ControlMediaBuyWorking, -) -from adcp.types.domains.media_buy.control_media_buy_request import ControlMediaBuyRequest -from adcp.types.domains.media_buy.control_media_buy_response import ( - AffectedPackageId, - ControlMediaBuyResponse, - ControlMediaBuyResponse1, - ControlMediaBuyResponse2, - ControlMediaBuyResponse3, -) -from adcp.types.domains.media_buy.create_media_buy_async_response_input_required import ( - CreateMediaBuyInputRequired, -) -from adcp.types.domains.media_buy.create_media_buy_async_response_submitted import ( - CreateMediaBuySubmitted, -) -from adcp.types.domains.media_buy.create_media_buy_async_response_working import ( - CreateMediaBuyWorking, -) -from adcp.types.domains.media_buy.create_media_buy_request import ( - ArtifactWebhook, - Authentication, - BatchFrequency, - CreateMediaBuyRequest, - DeliveryMode, -) -from adcp.types.domains.media_buy.create_media_buy_response import ( - CreateMediaBuyResponse, - CreateMediaBuyResponse1, - CreateMediaBuyResponse2, - CreateMediaBuyResponse3, -) -from adcp.types.domains.media_buy.decline_proposals_async_response_input_required import ( - DeclineProposalsInputRequired, -) -from adcp.types.domains.media_buy.decline_proposals_async_response_submitted import ( - DeclineProposalsSubmitted, -) -from adcp.types.domains.media_buy.decline_proposals_async_response_working import ( - DeclineProposalsWorking, -) -from adcp.types.domains.media_buy.decline_proposals_request import DeclineProposalsRequest -from adcp.types.domains.media_buy.decline_proposals_response import ( - DeclineProposalsResponse, - DeclineProposalsResponse1, - DeclineProposalsResponse2, - Results1, - Results2, - Results3, - Results4, - Results5, -) -from adcp.types.domains.media_buy.get_media_buy_delivery_request import ( - AttributionWindow, - CatalogItem, - Demographic, - DevicePlatform, - DeviceType, - Format, - Geo, - GetMediaBuyDeliveryRequest, - Keyword, - Placement, - ReportingDimensions, - Spot, -) -from adcp.types.domains.media_buy.get_media_buy_delivery_response import ( - AggregatedTotals, - ByAudienceItem, - ByDemographicItem, - ByDevicePlatformItem, - ByDeviceTypeItem, - ByFormatItem, - ByPackageItem1, - BySpotItem, - DailyBreakdownItem, - DailyBreakdownItem1, - GetMediaBuyDeliveryResponse, - ReportingRevisionBinding, - Window, -) -from adcp.types.domains.media_buy.get_media_buys_request import GetMediaBuysRequest -from adcp.types.domains.media_buy.get_media_buys_response import ( - Cancellation, - Cancellation1, - CreativeApproval, - GetMediaBuysResponse, - HistoryItem, - Indicator, - Indicator1, - Indicator2, - IndicatorTypesEvaluatedEnum, - IndicatorTypesEvaluatedEnum1, - IndicatorTypesEvaluatedEnum2, - MediaBuy, - Package, - Snapshot, -) -from adcp.types.domains.media_buy.get_products_async_response_input_required import ( - GetProductsInputRequired, -) -from adcp.types.domains.media_buy.get_products_async_response_submitted import GetProductsSubmitted -from adcp.types.domains.media_buy.get_products_async_response_working import GetProductsWorking -from adcp.types.domains.media_buy.get_products_rejected import GetProductsRejected -from adcp.types.domains.media_buy.get_products_request import ( - Action9, - BuyingMode, - Field1, - Fields, - GetProductsRequest, - Refine, - Refine1, - Refine2, - Refine3, -) -from adcp.types.domains.media_buy.get_products_response import ( - ExcludedBy, - Extensions, - FilterDiagnostics, - GetProductsResponse, - RefinementApplied, - RefinementApplied1, - RefinementApplied2, - RefinementApplied3, - Semantics, -) -from adcp.types.domains.media_buy.get_products_targeting_resolution import ( - ProductDiscoveryTargetingResolution, -) -from adcp.types.domains.media_buy.get_reporting_status_request import ( - DeliveryConfigId, - GetReportingStatusRequest, - Period, - ReportingStatusView, -) -from adcp.types.domains.media_buy.get_reporting_status_response import ( - DeliveryConfigGeneration, - GetReportingStatusResponse, - ObligationCounts, -) -from adcp.types.domains.media_buy.legacy_purchase_continuation_input import ( - AcceptedLoss, - CompatibilityPurchaseCoordinatorInput, - SelectedProductId, -) -from adcp.types.domains.media_buy.list_creative_formats_request import ListCreativeFormatsRequest -from adcp.types.domains.media_buy.list_creative_formats_response import ( - CreativeAgent, - ListCreativeFormatsResponse, -) -from adcp.types.domains.media_buy.list_products_request import ListProductsRequest -from adcp.types.domains.media_buy.list_products_response import ( - IncompleteItem3, - ListProductsResponse, - ListProductsResponse1, - ListProductsResponse2, -) -from adcp.types.domains.media_buy.log_event_request import LogEventRequest -from adcp.types.domains.media_buy.log_event_response import ( - LogEventResponse, - LogEventResponse1, - LogEventResponse2, - PartialFailure, -) -from adcp.types.domains.media_buy.media_buy_commitment_response import ( - MediaBuyCommitmentResponse, - MediaBuyCommitmentResponse1, - MediaBuyCommitmentResponse2, - MediaBuyCommitmentResponse3, -) -from adcp.types.domains.media_buy.media_buy_delivery_webhook_result import ( - MediaBuyDeliveryWebhookResult, -) -from adcp.types.domains.media_buy.outcome_target import Goal, Goal1, OutcomeTarget -from adcp.types.domains.media_buy.package_control import PackageControl -from adcp.types.domains.media_buy.package_request import ( - CommittedMetrics, - CommittedMetrics1, - CommittedMetrics2, - PackageRequest, - Qualifier, -) -from adcp.types.domains.media_buy.package_update import ( - KeywordTargetsAddItem, - KeywordTargetsRemoveItem, - NegativeKeywordsAddItem, - NegativeKeywordsRemoveItem, - PackageUpdate, -) -from adcp.types.domains.media_buy.product_discovery_criteria import ProductDiscoveryCriteria -from adcp.types.domains.media_buy.product_fields import ProductResponseField, ProductResponseFields -from adcp.types.domains.media_buy.product_purchase import ProductPurchase -from adcp.types.domains.media_buy.product_purchase_input import ProductPurchaseInput -from adcp.types.domains.media_buy.product_refinement import ( - Action11, - ProductRefinementRequests, - ProductRefinementRequests1, - ProductRefinementRequests2, - ProductRefinementRequests3, - ProductRefinementRequests4, -) -from adcp.types.domains.media_buy.proposal_budget_constraint import ProposalBudgetConstraint -from adcp.types.domains.media_buy.proposal_decline import ProposalDecline -from adcp.types.domains.media_buy.proposal_refinement import ( - Alternatives, - ChangeKind, - Constraints, - Constraints1, - Constraints2, - Constraints3, - Constraints4, - Constraints5, - Constraints6, - Constraints7, - Cpm, - Flight, - Impressions, - ProposalRefinement, - ProposalRefinement2, - ProposalRefinement3, - ProposalRefinement4, - ProposalRefinement5, - ProposalRefinement6, - ProposalRefinement7, - ProposalRefinement8, - ProposalRefinement9, -) -from adcp.types.domains.media_buy.provide_performance_feedback_request import ( - ProvidePerformanceFeedbackRequest, -) -from adcp.types.domains.media_buy.provide_performance_feedback_response import ( - ProvidePerformanceFeedbackResponse, - ProvidePerformanceFeedbackResponse1, - ProvidePerformanceFeedbackResponse2, -) -from adcp.types.domains.media_buy.refine_proposals_async_response_input_required import ( - RefineProposalsInputRequired, -) -from adcp.types.domains.media_buy.refine_proposals_async_response_submitted import ( - RefineProposalsSubmitted, -) -from adcp.types.domains.media_buy.refine_proposals_async_response_working import ( - RefineProposalsWorking, -) -from adcp.types.domains.media_buy.refine_proposals_request import ( - RefineProposalsRequest, - Refinements, -) -from adcp.types.domains.media_buy.refine_proposals_response import ( - Proposal2, - Proposal3, - Proposal4, - Proposal5, - Proposal6, - ProposalKind, - RefineProposalsResponse, - RefineProposalsResponse1, - RefineProposalsResponse2, - Results10, - Results11, - Results12, - Results14, - Results15, - Results16, - Results17, - Results18, - Results8, - Results9, - TotalBudgetGuidance, - UnsatisfiedConstraint, -) -from adcp.types.domains.media_buy.request_proposals_async_response_input_required import ( - RequestProposalsInputRequired, -) -from adcp.types.domains.media_buy.request_proposals_async_response_submitted import ( - RequestProposalsSubmitted, -) -from adcp.types.domains.media_buy.request_proposals_async_response_working import ( - RequestProposalsWorking, -) -from adcp.types.domains.media_buy.request_proposals_request import RequestProposalsRequest -from adcp.types.domains.media_buy.request_proposals_response import ( - IncompleteItem5, - IncompleteItem6, - IncompleteItem7, - Loss, - Losses, - PurchaseContinuation, - PurchaseContinuation1, - PurchaseContinuation2, - PurchaseContinuation3, - PurchaseContinuation4, - PurchaseContinuation5, - PurchaseContinuation6, - PurchaseContinuation7, - RequestProposalsResponse, - RequestProposalsResponse1, - RequestProposalsResponse2, - RequestProposalsResponse3, - RequestProposalsResponse4, - SourceAdcpVersion, -) -from adcp.types.domains.media_buy.sync_audiences_request import ( - AudienceType, - SyncAudiencesRequest, - Tag, -) -from adcp.types.domains.media_buy.sync_audiences_response import ( - MatchBreakdown, - SyncAudiencesResponse, - SyncAudiencesResponse1, - SyncAudiencesResponse2, - SyncAudiencesResponse3, -) -from adcp.types.domains.media_buy.sync_catalogs_async_response_input_required import ( - SyncCatalogsInputRequired, -) -from adcp.types.domains.media_buy.sync_catalogs_async_response_submitted import ( - SyncCatalogsSubmitted, -) -from adcp.types.domains.media_buy.sync_catalogs_async_response_working import SyncCatalogsWorking -from adcp.types.domains.media_buy.sync_catalogs_request import SyncCatalogsRequest -from adcp.types.domains.media_buy.sync_catalogs_response import ( - Catalog, - ItemIssue, - SyncCatalogsResponse, - SyncCatalogsResponse1, - SyncCatalogsResponse2, - SyncCatalogsResponse3, -) -from adcp.types.domains.media_buy.sync_event_sources_request import ( - SyncEventSourcesRequest, - ValueCurrency, -) -from adcp.types.domains.media_buy.sync_event_sources_response import ( - Setup, - SyncEventSourcesResponse, - SyncEventSourcesResponse1, - SyncEventSourcesResponse2, -) -from adcp.types.domains.media_buy.sync_reporting_receipts_request import ( - SyncReportingReceiptsRequest, -) -from adcp.types.domains.media_buy.sync_reporting_receipts_response import ( - Results20, - Results21, - Results22, - Results23, - SyncReportingReceiptsResponse, -) -from adcp.types.domains.media_buy.sync_reporting_status_request import SyncReportingStatusRequest -from adcp.types.domains.media_buy.sync_reporting_status_response import ( - Results26, - Results27, - SyncReportingStatusResponse, -) -from adcp.types.domains.media_buy.update_media_buy_async_response_input_required import ( - UpdateMediaBuyInputRequired, -) -from adcp.types.domains.media_buy.update_media_buy_async_response_submitted import ( - UpdateMediaBuySubmitted, -) -from adcp.types.domains.media_buy.update_media_buy_async_response_working import ( - UpdateMediaBuyWorking, -) -from adcp.types.domains.media_buy.update_media_buy_request import UpdateMediaBuyRequest -from adcp.types.domains.media_buy.update_media_buy_response import ( - UpdateMediaBuyResponse, - UpdateMediaBuyResponse1, - UpdateMediaBuyResponse2, - UpdateMediaBuyResponse3, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.media_buy.accept_proposal_async_response_input_required import ( + AcceptProposalInputRequired, + ) + from adcp.types.domains.media_buy.accept_proposal_async_response_submitted import ( + AcceptProposalSubmitted, + ) + from adcp.types.domains.media_buy.accept_proposal_async_response_working import ( + AcceptProposalWorking, + ) + from adcp.types.domains.media_buy.accept_proposal_request import AcceptProposalRequest + from adcp.types.domains.media_buy.accept_proposal_response import ( + AcceptProposalResponse, + AcceptProposalResponse1, + AcceptProposalResponse2, + AcceptProposalResponse3, + AcceptProposalResponse4, + AcceptProposalResponse5, + AcceptProposalResponse6, + AcceptProposalResponse7, + ) + from adcp.types.domains.media_buy.acceptance_context import ( + AcceptanceContext, + AdvertiserJurisdiction, + DeliveryJurisdiction, + Subject, + ) + from adcp.types.domains.media_buy.acceptance_policy_catalog import AcceptancePolicyCatalog + from adcp.types.domains.media_buy.acceptance_policy_profile import ( + AcceptancePolicyProfile, + Coverage, + PolicyRef, + RegionAliase, + SubjectCategory, + ) + from adcp.types.domains.media_buy.acceptance_policy_profile_ref import ( + RegistryAcceptancePolicyProfileReference, + ) + from adcp.types.domains.media_buy.acceptance_policy_requirement import ( + AcceptancePolicyRequirement, + AcceptancePolicyRequirement1, + AcceptancePolicyRequirement10, + AcceptancePolicyRequirement11, + AcceptancePolicyRequirement12, + AcceptancePolicyRequirement13, + AcceptancePolicyRequirement14, + AcceptancePolicyRequirement15, + AcceptancePolicyRequirement16, + AcceptancePolicyRequirement17, + AcceptancePolicyRequirement2, + AcceptancePolicyRequirement3, + AcceptancePolicyRequirement4, + AcceptancePolicyRequirement5, + AcceptancePolicyRequirement6, + AcceptancePolicyRequirement7, + AcceptancePolicyRequirement8, + AcceptancePolicyRequirement9, + Criterion, + FormatId, + ) + from adcp.types.domains.media_buy.acceptance_policy_rule import ( + AcceptancePolicyRule, + Disposition, + ) + from adcp.types.domains.media_buy.build_creative_async_response_input_required import ( + BuildCreativeInputRequired, + ) + from adcp.types.domains.media_buy.build_creative_async_response_submitted import ( + BuildCreativeSubmitted, + ) + from adcp.types.domains.media_buy.build_creative_async_response_working import ( + BuildCreativeWorking, + ) + from adcp.types.domains.media_buy.build_creative_request import ( + BuildCreativeRequest, + Dimension, + KeepMode, + MaxSpend, + Mode, + PreviewInput, + SignalCondition, + SignalCondition1, + SignalCondition2, + SignalCondition3, + SignalCondition4, + SignalCondition5, + SignalCondition6, + SignalCondition7, + TargetCapabilityId, + VariantAxis, + ) + from adcp.types.domains.media_buy.build_creative_response import ( + BuildCreativeResponse, + BuildCreativeResponse1, + BuildCreativeResponse2, + BuildCreativeResponse3, + BuildCreativeResponse4, + BuildCreativeResponse5, + BuildCreativeResponse6, + CatalogItemRef, + Estimate, + Eval, + Input, + Input2, + PerLeaf, + Preview, + Preview2, + Preview3, + Preview4, + Variant, + ) + from adcp.types.domains.media_buy.buy_products_async_response_input_required import ( + BuyProductsInputRequired, + ) + from adcp.types.domains.media_buy.buy_products_async_response_submitted import ( + BuyProductsSubmitted, + ) + from adcp.types.domains.media_buy.buy_products_async_response_working import BuyProductsWorking + from adcp.types.domains.media_buy.buy_products_request import BuyProductsRequest + from adcp.types.domains.media_buy.buy_products_response import ( + BuyProductsResponse, + BuyProductsResponse1, + BuyProductsResponse2, + BuyProductsResponse3, + BuyProductsResponse4, + BuyProductsResponse5, + BuyProductsResponse6, + BuyProductsResponse7, + ) + from adcp.types.domains.media_buy.change_term import ( + AllowedStatus, + Condition, + MediaBuyChangeTerm, + ) + from adcp.types.domains.media_buy.change_term_constraints import ( + MediaBuyChangeTermConstraints, + MediaBuyChangeTermConstraints1, + MediaBuyChangeTermConstraints2, + MediaBuyChangeTermConstraints3, + MediaBuyChangeTermConstraints4, + Money, + ) + from adcp.types.domains.media_buy.commercial_terms import ( + CancellationTerms, + CommercialTerms, + Fee, + ReportingCommitment, + ) + from adcp.types.domains.media_buy.control_media_buy_async_response_input_required import ( + ControlMediaBuyInputRequired, + ) + from adcp.types.domains.media_buy.control_media_buy_async_response_submitted import ( + ControlMediaBuySubmitted, + ) + from adcp.types.domains.media_buy.control_media_buy_async_response_working import ( + ControlMediaBuyWorking, + ) + from adcp.types.domains.media_buy.control_media_buy_request import ControlMediaBuyRequest + from adcp.types.domains.media_buy.control_media_buy_response import ( + AffectedPackageId, + ControlMediaBuyResponse, + ControlMediaBuyResponse1, + ControlMediaBuyResponse2, + ControlMediaBuyResponse3, + ) + from adcp.types.domains.media_buy.create_media_buy_async_response_input_required import ( + CreateMediaBuyInputRequired, + ) + from adcp.types.domains.media_buy.create_media_buy_async_response_submitted import ( + CreateMediaBuySubmitted, + ) + from adcp.types.domains.media_buy.create_media_buy_async_response_working import ( + CreateMediaBuyWorking, + ) + from adcp.types.domains.media_buy.create_media_buy_request import ( + ArtifactWebhook, + Authentication, + BatchFrequency, + CreateMediaBuyRequest, + CreateMediaBuyRequestBranches, + CreateMediaBuyRequestCommittedProposalExecution, + CreateMediaBuyRequestExplicitPackagesWithFixedAllocation, + CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation, + DeliveryMode, + ) + from adcp.types.domains.media_buy.create_media_buy_response import ( + CreateMediaBuyResponse, + CreateMediaBuyResponse1, + CreateMediaBuyResponse2, + CreateMediaBuyResponse3, + ) + from adcp.types.domains.media_buy.decline_proposals_async_response_input_required import ( + DeclineProposalsInputRequired, + ) + from adcp.types.domains.media_buy.decline_proposals_async_response_submitted import ( + DeclineProposalsSubmitted, + ) + from adcp.types.domains.media_buy.decline_proposals_async_response_working import ( + DeclineProposalsWorking, + ) + from adcp.types.domains.media_buy.decline_proposals_request import DeclineProposalsRequest + from adcp.types.domains.media_buy.decline_proposals_response import ( + DeclineProposalsResponse, + DeclineProposalsResponse1, + DeclineProposalsResponse2, + Results1, + Results2, + Results3, + Results4, + Results5, + ) + from adcp.types.domains.media_buy.get_media_buy_delivery_request import ( + AttributionWindow, + CatalogItem, + Demographic, + DevicePlatform, + DeviceType, + Format, + Geo, + GetMediaBuyDeliveryRequest, + Keyword, + Placement, + ReportingDimensions, + Spot, + ) + from adcp.types.domains.media_buy.get_media_buy_delivery_response import ( + AggregatedTotals, + ByAudienceItem, + ByDemographicItem, + ByDevicePlatformItem, + ByDeviceTypeItem, + ByFormatItem, + ByPackageItem1, + BySpotItem, + DailyBreakdownItem, + DailyBreakdownItem1, + GetMediaBuyDeliveryResponse, + ReportingRevisionBinding, + Window, + ) + from adcp.types.domains.media_buy.get_media_buys_request import GetMediaBuysRequest + from adcp.types.domains.media_buy.get_media_buys_response import ( + Cancellation, + Cancellation1, + CreativeApproval, + GetMediaBuysResponse, + HistoryItem, + Indicator, + Indicator1, + Indicator2, + IndicatorTypesEvaluatedEnum, + IndicatorTypesEvaluatedEnum1, + IndicatorTypesEvaluatedEnum2, + MediaBuy, + Package, + Snapshot, + ) + from adcp.types.domains.media_buy.get_products_async_response_input_required import ( + GetProductsInputRequired, + ) + from adcp.types.domains.media_buy.get_products_async_response_submitted import ( + GetProductsSubmitted, + ) + from adcp.types.domains.media_buy.get_products_async_response_working import GetProductsWorking + from adcp.types.domains.media_buy.get_products_rejected import GetProductsRejected + from adcp.types.domains.media_buy.get_products_request import ( + Action9, + BuyingMode, + Field1, + Fields, + GetProductsRequest, + Refine, + Refine1, + Refine2, + Refine3, + ) + from adcp.types.domains.media_buy.get_products_response import ( + ExcludedBy, + Extensions, + FilterDiagnostics, + GetProductsResponse, + RefinementApplied, + RefinementApplied1, + RefinementApplied2, + RefinementApplied3, + Semantics, + ) + from adcp.types.domains.media_buy.get_products_targeting_resolution import ( + ProductDiscoveryTargetingResolution, + ) + from adcp.types.domains.media_buy.get_reporting_status_request import ( + DeliveryConfigId, + GetReportingStatusRequest, + Period, + ReportingStatusView, + ) + from adcp.types.domains.media_buy.get_reporting_status_response import ( + DeliveryConfigGeneration, + GetReportingStatusResponse, + ObligationCounts, + ) + from adcp.types.domains.media_buy.legacy_purchase_continuation_input import ( + AcceptedLoss, + CompatibilityPurchaseCoordinatorInput, + SelectedProductId, + ) + from adcp.types.domains.media_buy.list_creative_formats_request import ( + ListCreativeFormatsRequest, + ) + from adcp.types.domains.media_buy.list_creative_formats_response import ( + CreativeAgent, + ListCreativeFormatsResponse, + ) + from adcp.types.domains.media_buy.list_products_request import ListProductsRequest + from adcp.types.domains.media_buy.list_products_response import ( + IncompleteItem3, + ListProductsResponse, + ListProductsResponse1, + ListProductsResponse2, + ) + from adcp.types.domains.media_buy.log_event_request import LogEventRequest + from adcp.types.domains.media_buy.log_event_response import ( + LogEventResponse, + LogEventResponse1, + LogEventResponse2, + PartialFailure, + ) + from adcp.types.domains.media_buy.media_buy_commitment_response import ( + MediaBuyCommitmentResponse, + MediaBuyCommitmentResponse1, + MediaBuyCommitmentResponse2, + MediaBuyCommitmentResponse3, + ) + from adcp.types.domains.media_buy.media_buy_delivery_webhook_result import ( + MediaBuyDeliveryWebhookResult, + ) + from adcp.types.domains.media_buy.outcome_target import Goal, Goal1, OutcomeTarget + from adcp.types.domains.media_buy.package_control import PackageControl + from adcp.types.domains.media_buy.package_request import ( + CommittedMetrics, + CommittedMetrics1, + CommittedMetrics2, + PackageRequest, + Qualifier, + ) + from adcp.types.domains.media_buy.package_update import ( + KeywordTargetsAddItem, + KeywordTargetsRemoveItem, + NegativeKeywordsAddItem, + NegativeKeywordsRemoveItem, + PackageUpdate, + ) + from adcp.types.domains.media_buy.product_discovery_criteria import ProductDiscoveryCriteria + from adcp.types.domains.media_buy.product_fields import ( + ProductResponseField, + ProductResponseFields, + ) + from adcp.types.domains.media_buy.product_purchase import ProductPurchase + from adcp.types.domains.media_buy.product_purchase_input import ProductPurchaseInput + from adcp.types.domains.media_buy.product_refinement import ( + Action11, + ProductRefinementRequests, + ProductRefinementRequests1, + ProductRefinementRequests2, + ProductRefinementRequests3, + ProductRefinementRequests4, + ) + from adcp.types.domains.media_buy.proposal_budget_constraint import ProposalBudgetConstraint + from adcp.types.domains.media_buy.proposal_decline import ProposalDecline + from adcp.types.domains.media_buy.proposal_refinement import ( + Alternatives, + ChangeKind, + Constraints, + Constraints1, + Constraints2, + Constraints3, + Constraints4, + Constraints5, + Constraints6, + Constraints7, + Cpm, + Flight, + Impressions, + ProposalRefinement, + ProposalRefinement2, + ProposalRefinement3, + ProposalRefinement4, + ProposalRefinement5, + ProposalRefinement6, + ProposalRefinement7, + ProposalRefinement8, + ProposalRefinement9, + ) + from adcp.types.domains.media_buy.provide_performance_feedback_request import ( + ProvidePerformanceFeedbackRequest, + ) + from adcp.types.domains.media_buy.provide_performance_feedback_response import ( + ProvidePerformanceFeedbackResponse, + ProvidePerformanceFeedbackResponse1, + ProvidePerformanceFeedbackResponse2, + ) + from adcp.types.domains.media_buy.refine_proposals_async_response_input_required import ( + RefineProposalsInputRequired, + ) + from adcp.types.domains.media_buy.refine_proposals_async_response_submitted import ( + RefineProposalsSubmitted, + ) + from adcp.types.domains.media_buy.refine_proposals_async_response_working import ( + RefineProposalsWorking, + ) + from adcp.types.domains.media_buy.refine_proposals_request import ( + RefineProposalsRequest, + Refinements, + ) + from adcp.types.domains.media_buy.refine_proposals_response import ( + Proposal2, + Proposal3, + Proposal4, + Proposal5, + Proposal6, + ProposalKind, + RefineProposalsResponse, + RefineProposalsResponse1, + RefineProposalsResponse2, + Results10, + Results11, + Results12, + Results14, + Results15, + Results16, + Results17, + Results18, + Results8, + Results9, + TotalBudgetGuidance, + UnsatisfiedConstraint, + ) + from adcp.types.domains.media_buy.request_proposals_async_response_input_required import ( + RequestProposalsInputRequired, + ) + from adcp.types.domains.media_buy.request_proposals_async_response_submitted import ( + RequestProposalsSubmitted, + ) + from adcp.types.domains.media_buy.request_proposals_async_response_working import ( + RequestProposalsWorking, + ) + from adcp.types.domains.media_buy.request_proposals_request import ( + RequestProposalsRequest, + RequestProposalsRequestAccount, + RequestProposalsRequestBranches, + RequestProposalsRequestBrand, + ) + from adcp.types.domains.media_buy.request_proposals_response import ( + IncompleteItem5, + IncompleteItem6, + IncompleteItem7, + Loss, + Losses, + PurchaseContinuation, + PurchaseContinuation1, + PurchaseContinuation2, + PurchaseContinuation3, + PurchaseContinuation4, + PurchaseContinuation5, + PurchaseContinuation6, + PurchaseContinuation7, + RequestProposalsResponse, + RequestProposalsResponse1, + RequestProposalsResponse2, + RequestProposalsResponse3, + RequestProposalsResponse4, + SourceAdcpVersion, + ) + from adcp.types.domains.media_buy.sync_audiences_request import ( + AudienceType, + SyncAudiencesRequest, + Tag, + ) + from adcp.types.domains.media_buy.sync_audiences_response import ( + MatchBreakdown, + SyncAudiencesResponse, + SyncAudiencesResponse1, + SyncAudiencesResponse2, + SyncAudiencesResponse3, + ) + from adcp.types.domains.media_buy.sync_catalogs_async_response_input_required import ( + SyncCatalogsInputRequired, + ) + from adcp.types.domains.media_buy.sync_catalogs_async_response_submitted import ( + SyncCatalogsSubmitted, + ) + from adcp.types.domains.media_buy.sync_catalogs_async_response_working import ( + SyncCatalogsWorking, + ) + from adcp.types.domains.media_buy.sync_catalogs_request import SyncCatalogsRequest + from adcp.types.domains.media_buy.sync_catalogs_response import ( + Catalog, + ItemIssue, + SyncCatalogsResponse, + SyncCatalogsResponse1, + SyncCatalogsResponse2, + SyncCatalogsResponse3, + ) + from adcp.types.domains.media_buy.sync_event_sources_request import ( + SyncEventSourcesRequest, + ValueCurrency, + ) + from adcp.types.domains.media_buy.sync_event_sources_response import ( + Setup, + SyncEventSourcesResponse, + SyncEventSourcesResponse1, + SyncEventSourcesResponse2, + ) + from adcp.types.domains.media_buy.sync_reporting_receipts_request import ( + SyncReportingReceiptsRequest, + ) + from adcp.types.domains.media_buy.sync_reporting_receipts_response import ( + Results20, + Results21, + Results22, + Results23, + SyncReportingReceiptsResponse, + ) + from adcp.types.domains.media_buy.sync_reporting_status_request import ( + SyncReportingStatusRequest, + ) + from adcp.types.domains.media_buy.sync_reporting_status_response import ( + Results26, + Results27, + SyncReportingStatusResponse, + ) + from adcp.types.domains.media_buy.update_media_buy_async_response_input_required import ( + UpdateMediaBuyInputRequired, + ) + from adcp.types.domains.media_buy.update_media_buy_async_response_submitted import ( + UpdateMediaBuySubmitted, + ) + from adcp.types.domains.media_buy.update_media_buy_async_response_working import ( + UpdateMediaBuyWorking, + ) + from adcp.types.domains.media_buy.update_media_buy_request import UpdateMediaBuyRequest + from adcp.types.domains.media_buy.update_media_buy_response import ( + UpdateMediaBuyResponse, + UpdateMediaBuyResponse1, + UpdateMediaBuyResponse2, + UpdateMediaBuyResponse3, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "media_buy.accept_proposal_async_response_input_required": ("AcceptProposalInputRequired",), + "media_buy.accept_proposal_async_response_submitted": ("AcceptProposalSubmitted",), + "media_buy.accept_proposal_async_response_working": ("AcceptProposalWorking",), + "media_buy.accept_proposal_request": ("AcceptProposalRequest",), + "media_buy.accept_proposal_response": ( + "AcceptProposalResponse", + "AcceptProposalResponse1", + "AcceptProposalResponse2", + "AcceptProposalResponse3", + "AcceptProposalResponse4", + "AcceptProposalResponse5", + "AcceptProposalResponse6", + "AcceptProposalResponse7", + ), + "media_buy.acceptance_context": ( + "AcceptanceContext", + "AdvertiserJurisdiction", + "DeliveryJurisdiction", + "Subject", + ), + "media_buy.acceptance_policy_catalog": ("AcceptancePolicyCatalog",), + "media_buy.acceptance_policy_profile": ( + "AcceptancePolicyProfile", + "Coverage", + "PolicyRef", + "RegionAliase", + "SubjectCategory", + ), + "media_buy.acceptance_policy_profile_ref": ("RegistryAcceptancePolicyProfileReference",), + "media_buy.acceptance_policy_requirement": ( + "AcceptancePolicyRequirement", + "AcceptancePolicyRequirement1", + "AcceptancePolicyRequirement10", + "AcceptancePolicyRequirement11", + "AcceptancePolicyRequirement12", + "AcceptancePolicyRequirement13", + "AcceptancePolicyRequirement14", + "AcceptancePolicyRequirement15", + "AcceptancePolicyRequirement16", + "AcceptancePolicyRequirement17", + "AcceptancePolicyRequirement2", + "AcceptancePolicyRequirement3", + "AcceptancePolicyRequirement4", + "AcceptancePolicyRequirement5", + "AcceptancePolicyRequirement6", + "AcceptancePolicyRequirement7", + "AcceptancePolicyRequirement8", + "AcceptancePolicyRequirement9", + "Criterion", + "FormatId", + ), + "media_buy.acceptance_policy_rule": ( + "AcceptancePolicyRule", + "Disposition", + ), + "media_buy.build_creative_async_response_input_required": ("BuildCreativeInputRequired",), + "media_buy.build_creative_async_response_submitted": ("BuildCreativeSubmitted",), + "media_buy.build_creative_async_response_working": ("BuildCreativeWorking",), + "media_buy.build_creative_request": ( + "BuildCreativeRequest", + "Dimension", + "KeepMode", + "MaxSpend", + "Mode", + "PreviewInput", + "SignalCondition", + "SignalCondition1", + "SignalCondition2", + "SignalCondition3", + "SignalCondition4", + "SignalCondition5", + "SignalCondition6", + "SignalCondition7", + "TargetCapabilityId", + "VariantAxis", + ), + "media_buy.build_creative_response": ( + "BuildCreativeResponse", + "BuildCreativeResponse1", + "BuildCreativeResponse2", + "BuildCreativeResponse3", + "BuildCreativeResponse4", + "BuildCreativeResponse5", + "BuildCreativeResponse6", + "CatalogItemRef", + "Estimate", + "Eval", + "Input", + "Input2", + "PerLeaf", + "Preview", + "Preview2", + "Preview3", + "Preview4", + "Variant", + ), + "media_buy.buy_products_async_response_input_required": ("BuyProductsInputRequired",), + "media_buy.buy_products_async_response_submitted": ("BuyProductsSubmitted",), + "media_buy.buy_products_async_response_working": ("BuyProductsWorking",), + "media_buy.buy_products_request": ("BuyProductsRequest",), + "media_buy.buy_products_response": ( + "BuyProductsResponse", + "BuyProductsResponse1", + "BuyProductsResponse2", + "BuyProductsResponse3", + "BuyProductsResponse4", + "BuyProductsResponse5", + "BuyProductsResponse6", + "BuyProductsResponse7", + ), + "media_buy.change_term": ( + "AllowedStatus", + "Condition", + "MediaBuyChangeTerm", + ), + "media_buy.change_term_constraints": ( + "MediaBuyChangeTermConstraints", + "MediaBuyChangeTermConstraints1", + "MediaBuyChangeTermConstraints2", + "MediaBuyChangeTermConstraints3", + "MediaBuyChangeTermConstraints4", + "Money", + ), + "media_buy.commercial_terms": ( + "CancellationTerms", + "CommercialTerms", + "Fee", + "ReportingCommitment", + ), + "media_buy.control_media_buy_async_response_input_required": ("ControlMediaBuyInputRequired",), + "media_buy.control_media_buy_async_response_submitted": ("ControlMediaBuySubmitted",), + "media_buy.control_media_buy_async_response_working": ("ControlMediaBuyWorking",), + "media_buy.control_media_buy_request": ("ControlMediaBuyRequest",), + "media_buy.control_media_buy_response": ( + "AffectedPackageId", + "ControlMediaBuyResponse", + "ControlMediaBuyResponse1", + "ControlMediaBuyResponse2", + "ControlMediaBuyResponse3", + ), + "media_buy.create_media_buy_async_response_input_required": ("CreateMediaBuyInputRequired",), + "media_buy.create_media_buy_async_response_submitted": ("CreateMediaBuySubmitted",), + "media_buy.create_media_buy_async_response_working": ("CreateMediaBuyWorking",), + "media_buy.create_media_buy_request": ( + "ArtifactWebhook", + "Authentication", + "BatchFrequency", + "CreateMediaBuyRequest", + "CreateMediaBuyRequestBranches", + "CreateMediaBuyRequestCommittedProposalExecution", + "CreateMediaBuyRequestExplicitPackagesWithFixedAllocation", + "CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation", + "DeliveryMode", + ), + "media_buy.create_media_buy_response": ( + "CreateMediaBuyResponse", + "CreateMediaBuyResponse1", + "CreateMediaBuyResponse2", + "CreateMediaBuyResponse3", + ), + "media_buy.decline_proposals_async_response_input_required": ("DeclineProposalsInputRequired",), + "media_buy.decline_proposals_async_response_submitted": ("DeclineProposalsSubmitted",), + "media_buy.decline_proposals_async_response_working": ("DeclineProposalsWorking",), + "media_buy.decline_proposals_request": ("DeclineProposalsRequest",), + "media_buy.decline_proposals_response": ( + "DeclineProposalsResponse", + "DeclineProposalsResponse1", + "DeclineProposalsResponse2", + "Results1", + "Results2", + "Results3", + "Results4", + "Results5", + ), + "media_buy.get_media_buy_delivery_request": ( + "AttributionWindow", + "CatalogItem", + "Demographic", + "DevicePlatform", + "DeviceType", + "Format", + "Geo", + "GetMediaBuyDeliveryRequest", + "Keyword", + "Placement", + "ReportingDimensions", + "Spot", + ), + "media_buy.get_media_buy_delivery_response": ( + "AggregatedTotals", + "ByAudienceItem", + "ByDemographicItem", + "ByDevicePlatformItem", + "ByDeviceTypeItem", + "ByFormatItem", + "ByPackageItem1", + "BySpotItem", + "DailyBreakdownItem", + "DailyBreakdownItem1", + "GetMediaBuyDeliveryResponse", + "ReportingRevisionBinding", + "Window", + ), + "media_buy.get_media_buys_request": ("GetMediaBuysRequest",), + "media_buy.get_media_buys_response": ( + "Cancellation", + "Cancellation1", + "CreativeApproval", + "GetMediaBuysResponse", + "HistoryItem", + "Indicator", + "Indicator1", + "Indicator2", + "IndicatorTypesEvaluatedEnum", + "IndicatorTypesEvaluatedEnum1", + "IndicatorTypesEvaluatedEnum2", + "MediaBuy", + "Package", + "Snapshot", + ), + "media_buy.get_products_async_response_input_required": ("GetProductsInputRequired",), + "media_buy.get_products_async_response_submitted": ("GetProductsSubmitted",), + "media_buy.get_products_async_response_working": ("GetProductsWorking",), + "media_buy.get_products_rejected": ("GetProductsRejected",), + "media_buy.get_products_request": ( + "Action9", + "BuyingMode", + "Field1", + "Fields", + "GetProductsRequest", + "Refine", + "Refine1", + "Refine2", + "Refine3", + ), + "media_buy.get_products_response": ( + "ExcludedBy", + "Extensions", + "FilterDiagnostics", + "GetProductsResponse", + "RefinementApplied", + "RefinementApplied1", + "RefinementApplied2", + "RefinementApplied3", + "Semantics", + ), + "media_buy.get_products_targeting_resolution": ("ProductDiscoveryTargetingResolution",), + "media_buy.get_reporting_status_request": ( + "DeliveryConfigId", + "GetReportingStatusRequest", + "Period", + "ReportingStatusView", + ), + "media_buy.get_reporting_status_response": ( + "DeliveryConfigGeneration", + "GetReportingStatusResponse", + "ObligationCounts", + ), + "media_buy.legacy_purchase_continuation_input": ( + "AcceptedLoss", + "CompatibilityPurchaseCoordinatorInput", + "SelectedProductId", + ), + "media_buy.list_creative_formats_request": ("ListCreativeFormatsRequest",), + "media_buy.list_creative_formats_response": ( + "CreativeAgent", + "ListCreativeFormatsResponse", + ), + "media_buy.list_products_request": ("ListProductsRequest",), + "media_buy.list_products_response": ( + "IncompleteItem3", + "ListProductsResponse", + "ListProductsResponse1", + "ListProductsResponse2", + ), + "media_buy.log_event_request": ("LogEventRequest",), + "media_buy.log_event_response": ( + "LogEventResponse", + "LogEventResponse1", + "LogEventResponse2", + "PartialFailure", + ), + "media_buy.media_buy_commitment_response": ( + "MediaBuyCommitmentResponse", + "MediaBuyCommitmentResponse1", + "MediaBuyCommitmentResponse2", + "MediaBuyCommitmentResponse3", + ), + "media_buy.media_buy_delivery_webhook_result": ("MediaBuyDeliveryWebhookResult",), + "media_buy.outcome_target": ( + "Goal", + "Goal1", + "OutcomeTarget", + ), + "media_buy.package_control": ("PackageControl",), + "media_buy.package_request": ( + "CommittedMetrics", + "CommittedMetrics1", + "CommittedMetrics2", + "PackageRequest", + "Qualifier", + ), + "media_buy.package_update": ( + "KeywordTargetsAddItem", + "KeywordTargetsRemoveItem", + "NegativeKeywordsAddItem", + "NegativeKeywordsRemoveItem", + "PackageUpdate", + ), + "media_buy.product_discovery_criteria": ("ProductDiscoveryCriteria",), + "media_buy.product_fields": ( + "ProductResponseField", + "ProductResponseFields", + ), + "media_buy.product_purchase": ("ProductPurchase",), + "media_buy.product_purchase_input": ("ProductPurchaseInput",), + "media_buy.product_refinement": ( + "Action11", + "ProductRefinementRequests", + "ProductRefinementRequests1", + "ProductRefinementRequests2", + "ProductRefinementRequests3", + "ProductRefinementRequests4", + ), + "media_buy.proposal_budget_constraint": ("ProposalBudgetConstraint",), + "media_buy.proposal_decline": ("ProposalDecline",), + "media_buy.proposal_refinement": ( + "Alternatives", + "ChangeKind", + "Constraints", + "Constraints1", + "Constraints2", + "Constraints3", + "Constraints4", + "Constraints5", + "Constraints6", + "Constraints7", + "Cpm", + "Flight", + "Impressions", + "ProposalRefinement", + "ProposalRefinement2", + "ProposalRefinement3", + "ProposalRefinement4", + "ProposalRefinement5", + "ProposalRefinement6", + "ProposalRefinement7", + "ProposalRefinement8", + "ProposalRefinement9", + ), + "media_buy.provide_performance_feedback_request": ("ProvidePerformanceFeedbackRequest",), + "media_buy.provide_performance_feedback_response": ( + "ProvidePerformanceFeedbackResponse", + "ProvidePerformanceFeedbackResponse1", + "ProvidePerformanceFeedbackResponse2", + ), + "media_buy.refine_proposals_async_response_input_required": ("RefineProposalsInputRequired",), + "media_buy.refine_proposals_async_response_submitted": ("RefineProposalsSubmitted",), + "media_buy.refine_proposals_async_response_working": ("RefineProposalsWorking",), + "media_buy.refine_proposals_request": ( + "RefineProposalsRequest", + "Refinements", + ), + "media_buy.refine_proposals_response": ( + "Proposal2", + "Proposal3", + "Proposal4", + "Proposal5", + "Proposal6", + "ProposalKind", + "RefineProposalsResponse", + "RefineProposalsResponse1", + "RefineProposalsResponse2", + "Results10", + "Results11", + "Results12", + "Results14", + "Results15", + "Results16", + "Results17", + "Results18", + "Results8", + "Results9", + "TotalBudgetGuidance", + "UnsatisfiedConstraint", + ), + "media_buy.request_proposals_async_response_input_required": ("RequestProposalsInputRequired",), + "media_buy.request_proposals_async_response_submitted": ("RequestProposalsSubmitted",), + "media_buy.request_proposals_async_response_working": ("RequestProposalsWorking",), + "media_buy.request_proposals_request": ( + "RequestProposalsRequest", + "RequestProposalsRequestAccount", + "RequestProposalsRequestBranches", + "RequestProposalsRequestBrand", + ), + "media_buy.request_proposals_response": ( + "IncompleteItem5", + "IncompleteItem6", + "IncompleteItem7", + "Loss", + "Losses", + "PurchaseContinuation", + "PurchaseContinuation1", + "PurchaseContinuation2", + "PurchaseContinuation3", + "PurchaseContinuation4", + "PurchaseContinuation5", + "PurchaseContinuation6", + "PurchaseContinuation7", + "RequestProposalsResponse", + "RequestProposalsResponse1", + "RequestProposalsResponse2", + "RequestProposalsResponse3", + "RequestProposalsResponse4", + "SourceAdcpVersion", + ), + "media_buy.sync_audiences_request": ( + "AudienceType", + "SyncAudiencesRequest", + "Tag", + ), + "media_buy.sync_audiences_response": ( + "MatchBreakdown", + "SyncAudiencesResponse", + "SyncAudiencesResponse1", + "SyncAudiencesResponse2", + "SyncAudiencesResponse3", + ), + "media_buy.sync_catalogs_async_response_input_required": ("SyncCatalogsInputRequired",), + "media_buy.sync_catalogs_async_response_submitted": ("SyncCatalogsSubmitted",), + "media_buy.sync_catalogs_async_response_working": ("SyncCatalogsWorking",), + "media_buy.sync_catalogs_request": ("SyncCatalogsRequest",), + "media_buy.sync_catalogs_response": ( + "Catalog", + "ItemIssue", + "SyncCatalogsResponse", + "SyncCatalogsResponse1", + "SyncCatalogsResponse2", + "SyncCatalogsResponse3", + ), + "media_buy.sync_event_sources_request": ( + "SyncEventSourcesRequest", + "ValueCurrency", + ), + "media_buy.sync_event_sources_response": ( + "Setup", + "SyncEventSourcesResponse", + "SyncEventSourcesResponse1", + "SyncEventSourcesResponse2", + ), + "media_buy.sync_reporting_receipts_request": ("SyncReportingReceiptsRequest",), + "media_buy.sync_reporting_receipts_response": ( + "Results20", + "Results21", + "Results22", + "Results23", + "SyncReportingReceiptsResponse", + ), + "media_buy.sync_reporting_status_request": ("SyncReportingStatusRequest",), + "media_buy.sync_reporting_status_response": ( + "Results26", + "Results27", + "SyncReportingStatusResponse", + ), + "media_buy.update_media_buy_async_response_input_required": ("UpdateMediaBuyInputRequired",), + "media_buy.update_media_buy_async_response_submitted": ("UpdateMediaBuySubmitted",), + "media_buy.update_media_buy_async_response_working": ("UpdateMediaBuyWorking",), + "media_buy.update_media_buy_request": ("UpdateMediaBuyRequest",), + "media_buy.update_media_buy_response": ( + "UpdateMediaBuyResponse", + "UpdateMediaBuyResponse1", + "UpdateMediaBuyResponse2", + "UpdateMediaBuyResponse3", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ @@ -645,6 +1193,10 @@ "Cpm", "CreateMediaBuyInputRequired", "CreateMediaBuyRequest", + "CreateMediaBuyRequestBranches", + "CreateMediaBuyRequestCommittedProposalExecution", + "CreateMediaBuyRequestExplicitPackagesWithFixedAllocation", + "CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation", "CreateMediaBuyResponse", "CreateMediaBuyResponse1", "CreateMediaBuyResponse2", @@ -828,6 +1380,9 @@ "ReportingStatusView", "RequestProposalsInputRequired", "RequestProposalsRequest", + "RequestProposalsRequestAccount", + "RequestProposalsRequestBranches", + "RequestProposalsRequestBrand", "RequestProposalsResponse", "RequestProposalsResponse1", "RequestProposalsResponse2", diff --git a/src/adcp/types/domains/media_buy/accept_proposal_request.py b/src/adcp/types/domains/media_buy/accept_proposal_request.py index db7a98330..c29195da0 100644 --- a/src/adcp/types/domains/media_buy/accept_proposal_request.py +++ b/src/adcp/types/domains/media_buy/accept_proposal_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/accept_proposal_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..core import canonical_account_ref from ..core import context as context_1 @@ -21,6 +21,12 @@ from ..core.version_envelope import AdcpVersionEnvelope +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class TotalBudget(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -45,12 +51,7 @@ class Status(StrEnum): class Opportunity(OpportunityContext): - status: Annotated[ - Literal['closed'], - Field( - description='Whether the planning cycle remains open. On calls after initial creation, omission means no status update and MUST NOT reopen or close the opportunity implicitly, except that successful create_media_buy proposal execution explicitly infers accepted closure.' - ), - ] = 'closed' + status: Annotated[Literal['closed'] | None, Field(description='Whether the planning cycle remains open. On calls after initial creation, omission means no status update and MUST NOT reopen or close the opportunity implicitly, except that successful create_media_buy proposal execution explicitly infers accepted closure.')] = None class AcceptProposalRequest(AdcpRequest, AdcpVersionEnvelope): @@ -116,3 +117,21 @@ class AcceptProposalRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AcceptProposalRequest: + # Compiled from media-buy/accept-proposal-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (False or False or False or False) + ): + raise PydanticCustomError( + 'allOf', + 'AcceptProposalRequest violates a constraint its schema states outside its properties (media-buy/accept-proposal-request.json, root conjunct 0) over creative_assignments, creatives, packages, purchases', + {'fields': 'creative_assignments, creatives, packages, purchases'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/acceptance_context.py b/src/adcp/types/domains/media_buy/acceptance_context.py index 82430b235..5539f5432 100644 --- a/src/adcp/types/domains/media_buy/acceptance_context.py +++ b/src/adcp/types/domains/media_buy/acceptance_context.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_context.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import ext as ext_1 from ..enums import advertiser_industry as advertiser_industry_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class SubjectFacet(ScalarStr): __slots__ = () _constraints = {'pattern': '^[a-z][a-z0-9_]*$'} @@ -62,3 +68,20 @@ class AcceptanceContext(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AcceptanceContext: + # Compiled from media-buy/acceptance-context.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'AcceptanceContext violates a constraint its schema states outside its properties (media-buy/acceptance-context.json, root conjunct 0)', + ) + return self diff --git a/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py b/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py index 26446d982..6aceaeae5 100644 --- a/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py +++ b/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_catalog.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field, model_validator @@ -13,6 +13,9 @@ from . import acceptance_policy_profile, acceptance_policy_profile_ref +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -33,19 +36,29 @@ class AcceptancePolicyCatalog(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'profiles', 'registry_profiles'}) @model_validator(mode='after') def _require_schema_required_group(self) -> AcceptancePolicyCatalog: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('profiles',), ('registry_profiles',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'AcceptancePolicyCatalog requires at least one of these field groups: profiles | registry_profiles', - ) + # Compiled from media-buy/acceptance-policy-catalog.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # profiles + ( + _c.sent(self, 'profiles') + ) + # registry_profiles + or ( + _c.sent(self, 'registry_profiles') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'AcceptancePolicyCatalog requires at least one of these field groups: profiles | registry_profiles', + ) + return self diff --git a/src/adcp/types/domains/media_buy/acceptance_policy_profile.py b/src/adcp/types/domains/media_buy/acceptance_policy_profile.py index 63d64d68b..2a4dd8d8e 100644 --- a/src/adcp/types/domains/media_buy/acceptance_policy_profile.py +++ b/src/adcp/types/domains/media_buy/acceptance_policy_profile.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_profile.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,13 +8,19 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, StringConstraints +from pydantic import ConfigDict, Field, StringConstraints, model_validator from ..core import ext as ext_1 from . import acceptance_policy_rule +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class PolicyRef(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -124,3 +130,21 @@ class AcceptancePolicyProfile(AdCPBaseModel): description: Annotated[str | None, Field(min_length=1)] = None rules: Annotated[list[acceptance_policy_rule.AcceptancePolicyRule], Field(min_length=1)] ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AcceptancePolicyProfile: + # Compiled from media-buy/acceptance-policy-profile.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'scope') if (_c.descend(self, 'coverage', lambda coverage: coverage == 'complete') and _c.sent(self, 'coverage')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AcceptancePolicyProfile violates a constraint its schema states outside its properties (media-buy/acceptance-policy-profile.json, root conjunct 0) over coverage, scope', + {'fields': 'coverage, scope'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/acceptance_policy_rule.py b/src/adcp/types/domains/media_buy/acceptance_policy_rule.py index 3186497ba..f2bcad64e 100644 --- a/src/adcp/types/domains/media_buy/acceptance_policy_rule.py +++ b/src/adcp/types/domains/media_buy/acceptance_policy_rule.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_rule.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,13 +8,19 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..core import ext as ext_1 from . import acceptance_policy_requirement +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class SubjectFacet(ScalarStr): __slots__ = () _constraints = {'pattern': '^[a-z][a-z0-9_]*$'} @@ -118,3 +124,21 @@ class AcceptancePolicyRule(AdCPBaseModel): effective_at: AwareDatetime | None = None expires_at: AwareDatetime | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> AcceptancePolicyRule: + # Compiled from media-buy/acceptance-policy-rule.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'requirements') if (_c.descend(self, 'disposition', lambda disposition: disposition == 'conditional') and _c.sent(self, 'disposition')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'AcceptancePolicyRule violates a constraint its schema states outside its properties (media-buy/acceptance-policy-rule.json, root conjunct 0) over disposition, requirements', + {'fields': 'disposition, requirements'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/build_creative_request.py b/src/adcp/types/domains/media_buy/build_creative_request.py index 6a3c5a0f4..f014df034 100644 --- a/src/adcp/types/domains/media_buy/build_creative_request.py +++ b/src/adcp/types/domains/media_buy/build_creative_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/build_creative_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,8 +10,7 @@ from typing import Annotated, Any, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import account_ref, brand_ref from ..core import context as context_1 @@ -30,6 +29,13 @@ from ..enums import representation_selection_strategy as representation_selection_strategy_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class TargetCapabilityId(ScalarStr): __slots__ = () _constraints = {'pattern': '^[a-zA-Z0-9_-]+$'} @@ -471,3 +477,89 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> BuildCreativeRequest: + # Compiled from media-buy/build-creative-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.sent(self, 'target_format_id') and _c.sent(self, 'target_format_ids')) + ): + raise PydanticCustomError( + 'allOf', + 'BuildCreativeRequest violates a constraint its schema states outside its properties (media-buy/build-creative-request.json, root conjunct 0) over target_format_id, target_format_ids', + {'fields': 'target_format_id, target_format_ids'}, + ) + if not ( + not (_c.sent(self, 'target_capability_id') and _c.sent(self, 'target_capability_ids')) + ): + raise PydanticCustomError( + 'allOf', + 'BuildCreativeRequest violates a constraint its schema states outside its properties (media-buy/build-creative-request.json, root conjunct 1) over target_capability_id, target_capability_ids', + {'fields': 'target_capability_id, target_capability_ids'}, + ) + if not ( + not ((_c.sent(self, 'target_capability_id') and _c.sent(self, 'target_format_id')) or (_c.sent(self, 'target_capability_id') and _c.sent(self, 'target_format_ids')) or (_c.sent(self, 'target_capability_ids') and _c.sent(self, 'target_format_id')) or (_c.sent(self, 'target_capability_ids') and _c.sent(self, 'target_format_ids'))) + ): + raise PydanticCustomError( + 'allOf', + 'BuildCreativeRequest violates a constraint its schema states outside its properties (media-buy/build-creative-request.json, root conjunct 2) over target_capability_id, target_capability_ids, target_format_id, target_format_ids', + {'fields': 'target_capability_id, target_capability_ids, target_format_id, target_format_ids'}, + ) + if not ( + (not (_c.sent(self, 'target_capability_id') or _c.sent(self, 'target_capability_ids') or _c.sent(self, 'target_format_id') or _c.sent(self, 'target_format_ids') or _c.sent(self, 'transformer_id')) and _c.sent(self, 'refine_from_build_variant_id')) + or (not (_c.sent(self, 'refine_from_build_variant_id')) and _c.sent(self, 'target_capability_id')) + or (not (_c.sent(self, 'refine_from_build_variant_id')) and _c.sent(self, 'target_capability_ids')) + or (not (_c.sent(self, 'refine_from_build_variant_id')) and _c.sent(self, 'target_format_id')) + or (not (_c.sent(self, 'refine_from_build_variant_id')) and _c.sent(self, 'target_format_ids')) + ): + raise PydanticCustomError( + 'allOf', + 'BuildCreativeRequest violates a constraint its schema states outside its properties (media-buy/build-creative-request.json, root conjunct 3) over refine_from_build_variant_id, target_capability_id, target_capability_ids, target_format_id, target_format_ids, transformer_id', + {'fields': 'refine_from_build_variant_id, target_capability_id, target_capability_ids, target_format_id, target_format_ids, transformer_id'}, + ) + if not ( + not (_c.sent(self, 'refine_from_build_variant_id') and _c.sent(self, 'max_creatives')) + ): + raise PydanticCustomError( + 'allOf', + 'BuildCreativeRequest violates a constraint its schema states outside its properties (media-buy/build-creative-request.json, root conjunct 4) over max_creatives, refine_from_build_variant_id', + {'fields': 'max_creatives, refine_from_build_variant_id'}, + ) + if not ( + not ((_c.sent(self, 'creative_representation_set') and _c.sent(self, 'creative_manifest')) or (_c.sent(self, 'creative_representation_set') and _c.sent(self, 'creative_id')) or (_c.sent(self, 'creative_representation_set') and _c.sent(self, 'refine_from_build_variant_id'))) + ): + raise PydanticCustomError( + 'allOf', + 'BuildCreativeRequest violates a constraint its schema states outside its properties (media-buy/build-creative-request.json, root conjunct 5) over creative_id, creative_manifest, creative_representation_set, refine_from_build_variant_id', + {'fields': 'creative_id, creative_manifest, creative_representation_set, refine_from_build_variant_id'}, + ) + if not ( + ((not (_c.sent(self, 'message') or _c.sent(self, 'creative_manifest') or _c.sent(self, 'creative_id') or _c.sent(self, 'transformer_id') or _c.sent(self, 'config') or _c.sent(self, 'refine_from_build_variant_id') or _c.sent(self, 'max_creatives') or _c.sent(self, 'signal_conditions') or _c.sent(self, 'max_variants') or _c.sent(self, 'variant_axis') or _c.sent(self, 'selection_strategy') or _c.sent(self, 'item_limit') or _c.sent(self, 'evaluator') or _c.sent(self, 'quality') or _c.sent(self, 'concept_id') or _c.sent(self, 'keep_mode') or _c.sent(self, 'max_spend') or _c.sent(self, 'brand') or _c.sent(self, 'media_buy_id') or _c.sent(self, 'package_id') or _c.sent(self, 'target_capability_ids') or _c.sent(self, 'target_format_id') or _c.sent(self, 'target_format_ids')) and _c.descend(self, 'mode', lambda mode: mode == 'execute') and (_c.sent(self, 'representation_destination') and _c.sent(self, 'representation_selection_strategy') and _c.sent(self, 'target_capability_id'))) if _c.sent(self, 'creative_representation_set') else True) + ): + raise PydanticCustomError( + 'allOf', + 'BuildCreativeRequest violates a constraint its schema states outside its properties (media-buy/build-creative-request.json, root conjunct 6) over brand, concept_id, config, creative_id, creative_manifest, creative_representation_set, evaluator, item_limit, keep_mode, max_creatives, max_spend, max_variants, media_buy_id, message, mode, package_id, quality, refine_from_build_variant_id, representation_destination, representation_selection_strategy, selection_strategy, signal_conditions, target_capability_id, target_capability_ids, target_format_id, target_format_ids, transformer_id, variant_axis', + {'fields': 'brand, concept_id, config, creative_id, creative_manifest, creative_representation_set, evaluator, item_limit, keep_mode, max_creatives, max_spend, max_variants, media_buy_id, message, mode, package_id, quality, refine_from_build_variant_id, representation_destination, representation_selection_strategy, selection_strategy, signal_conditions, target_capability_id, target_capability_ids, target_format_id, target_format_ids, transformer_id, variant_axis'}, + ) + if not ( + (_c.sent(self, 'creative_representation_set') if (_c.sent(self, 'representation_destination') or _c.sent(self, 'representation_selection_strategy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'BuildCreativeRequest violates a constraint its schema states outside its properties (media-buy/build-creative-request.json, root conjunct 7) over creative_representation_set, representation_destination, representation_selection_strategy', + {'fields': 'creative_representation_set, representation_destination, representation_selection_strategy'}, + ) + if not ( + ((_c.descend(self, 'representation_destination', lambda representation_destination: _c.descend(representation_destination, 'format_option', lambda format_option: _c.descend(format_option, 'format_kind', lambda format_kind: format_kind in ['video_vast', 'audio_vast']) and _c.supplied(format_option, 'format_kind')) and _c.sent(representation_destination, 'format_option')) and _c.sent(self, 'representation_destination')) if (_c.descend(self, 'representation_selection_strategy', lambda representation_selection_strategy: representation_selection_strategy == 'highest_compatible_vast') and _c.sent(self, 'representation_selection_strategy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'BuildCreativeRequest violates a constraint its schema states outside its properties (media-buy/build-creative-request.json, root conjunct 8) over format_kind, format_option, representation_destination, representation_selection_strategy', + {'fields': 'format_kind, format_option, representation_destination, representation_selection_strategy'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/build_creative_response.py b/src/adcp/types/domains/media_buy/build_creative_response.py index 46eea9ddf..cc69e6d79 100644 --- a/src/adcp/types/domains/media_buy/build_creative_response.py +++ b/src/adcp/types/domains/media_buy/build_creative_response.py @@ -25,6 +25,9 @@ from ..enums import task_status as task_status_1 +from adcp.types._readback import readback_clone + + class Input(AdcpVersionEnvelope): model_config = ConfigDict(extra='allow') name: str @@ -134,9 +137,31 @@ class Estimate(AdcpVersionEnvelope): per_leaf: list[PerLeaf] | None = None +_BuildReadbackVariant = readback_clone( + "_BuildReadbackVariant", + Variant, + {"creative_manifest": creative_manifest_1._ReadbackCreativeManifest}, +) + + +_BuildReadbackCreative = readback_clone( + "_BuildReadbackCreative", + Creative, + { + # This constraint is inside the optional union in the generated type, so it + # has to stay on the non-None branch when that annotation is replaced. + "variants": Annotated[list[_BuildReadbackVariant], Field(min_length=1)] | None + }, +) + + class BuildCreativeResponse1(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') - creative_manifest: creative_manifest_1.CreativeManifest + # A response carries the tolerant read-back node, not the strict input class: a seller may + # send an unknown field, or a manifest instance of a sibling class, and neither may become + # acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in + # scripts/post_generate_fixes.py. + creative_manifest: creative_manifest_1._ReadbackCreativeManifest build_variant_id: str | None = None recipe_hash: str | None = None sandbox: bool | None = None @@ -160,7 +185,11 @@ class BuildCreativeResponse2(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope class BuildCreativeResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') - creative_manifests: Annotated[list[creative_manifest_1.CreativeManifest], Field(min_length=1)] + # A response carries the tolerant read-back node, not the strict input class: a seller may + # send an unknown field, or a manifest instance of a sibling class, and neither may become + # acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in + # scripts/post_generate_fixes.py. + creative_manifests: Annotated[list[creative_manifest_1._ReadbackCreativeManifest], Field(min_length=1)] sandbox: bool | None = None expires_at: AwareDatetime | None = None preview: Preview3 | None = None @@ -175,7 +204,11 @@ class BuildCreativeResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope class BuildCreativeResponse4(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict(extra='allow') - creatives: Annotated[list[Creative], Field(min_length=1)] + # A response carries the tolerant read-back node, not the strict input class: a seller may + # send an unknown field, or a manifest instance of a sibling class, and neither may become + # acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in + # scripts/post_generate_fixes.py. + creatives: Annotated[list[_BuildReadbackCreative], Field(min_length=1)] items_total: Annotated[int, Field(ge=0)] | None = None items_returned: Annotated[int, Field(ge=0)] | None = None leaves_total: Annotated[int, Field(ge=0)] | None = None diff --git a/src/adcp/types/domains/media_buy/buy_products_request.py b/src/adcp/types/domains/media_buy/buy_products_request.py index 48e1630b5..ce7ef88ae 100644 --- a/src/adcp/types/domains/media_buy/buy_products_request.py +++ b/src/adcp/types/domains/media_buy/buy_products_request.py @@ -1,16 +1,16 @@ # generated by datamodel-codegen: # filename: media_buy/buy_products_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest from adcp.types._str_enum import StrEnum -from typing import Annotated, Literal +from typing import Annotated, ClassVar, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import ( bidding_policy, @@ -32,6 +32,12 @@ from . import product_purchase_input +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class TotalBudget(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -46,12 +52,7 @@ class Status(StrEnum): class Opportunity(OpportunityContext): - status: Annotated[ - Literal['closed'], - Field( - description='Whether the planning cycle remains open. On calls after initial creation, omission means no status update and MUST NOT reopen or close the opportunity implicitly, except that successful create_media_buy proposal execution explicitly infers accepted closure.' - ), - ] = 'closed' + status: Annotated[Literal['closed'] | None, Field(description='Whether the planning cycle remains open. On calls after initial creation, omission means no status update and MUST NOT reopen or close the opportunity implicitly, except that successful create_media_buy proposal execution explicitly infers accepted closure.')] = None class BuyProductsRequest(AdcpRequest, AdcpVersionEnvelope): @@ -157,3 +158,45 @@ class BuyProductsRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'account', 'brand', 'operator'}) + + @model_validator(mode='after') + def _require_schema_required_group(self) -> BuyProductsRequest: + # Compiled from media-buy/buy-products-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # brand + ( + _c.sent(self, 'brand') + ) + # anyOf/1 + or ( + _c.descend(self, 'account', lambda account: _c.sent(account, 'brand') and _c.sent(account, 'operator')) + ) + ): + raise PydanticCustomError( + 'anyOf', + 'BuyProductsRequest requires at least one of these field groups: brand | anyOf/1', + ) + if not ( + not (False or False or False or False or False) + ): + raise PydanticCustomError( + 'allOf', + 'BuyProductsRequest violates a constraint its schema states outside its properties (media-buy/buy-products-request.json, root conjunct 0) over artifact_webhook, creative_assignments, creatives, packages, proposal_id', + {'fields': 'artifact_webhook, creative_assignments, creatives, packages, proposal_id'}, + ) + if not ( + not (_c.descend(self, 'account', lambda account: _c.sent(account, 'brand') and _c.sent(account, 'operator')) and (_c.sent(self, 'account') and _c.sent(self, 'brand'))) + ): + raise PydanticCustomError( + 'allOf', + 'BuyProductsRequest violates a constraint its schema states outside its properties (media-buy/buy-products-request.json, root conjunct 1) over account, brand, operator', + {'fields': 'account, brand, operator'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/control_media_buy_request.py b/src/adcp/types/domains/media_buy/control_media_buy_request.py index 3c19c331c..9af892384 100644 --- a/src/adcp/types/domains/media_buy/control_media_buy_request.py +++ b/src/adcp/types/domains/media_buy/control_media_buy_request.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/control_media_buy_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest, SchemaInt -from typing import Annotated, Literal +from typing import Annotated, ClassVar, Literal from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator @@ -22,6 +22,9 @@ from . import package_control +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -107,19 +110,93 @@ class ControlMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): push_notification_config: push_notification_config_1.PushNotificationConfig | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'bidding', 'budget_allocation', 'budget_cap_timezone', 'canceled', 'daily_budget_cap', 'frequency_cap', 'name', 'pacing', 'packages', 'paused', 'reporting_webhook', 'total_budget'}) @model_validator(mode='after') def _require_schema_required_group(self) -> ControlMediaBuyRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('name',), ('paused',), ('canceled',), ('total_budget',), ('daily_budget_cap',), ('frequency_cap',), ('budget_cap_timezone',), ('budget_allocation',), ('pacing',), ('bidding',), ('packages',), ('reporting_webhook',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'ControlMediaBuyRequest requires at least one of these field groups: name | paused | canceled | total_budget | daily_budget_cap | frequency_cap | budget_cap_timezone | budget_allocation | pacing | bidding | packages | reporting_webhook', - ) + # Compiled from media-buy/control-media-buy-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # name + ( + _c.sent(self, 'name') + ) + # paused + or ( + _c.sent(self, 'paused') + ) + # canceled + or ( + _c.sent(self, 'canceled') + ) + # total_budget + or ( + _c.sent(self, 'total_budget') + ) + # daily_budget_cap + or ( + _c.supplied(self, 'daily_budget_cap') + ) + # frequency_cap + or ( + _c.supplied(self, 'frequency_cap') + ) + # budget_cap_timezone + or ( + _c.supplied(self, 'budget_cap_timezone') + ) + # budget_allocation + or ( + _c.sent(self, 'budget_allocation') + ) + # pacing + or ( + _c.sent(self, 'pacing') + ) + # bidding + or ( + _c.supplied(self, 'bidding') + ) + # packages + or ( + _c.sent(self, 'packages') + ) + # reporting_webhook + or ( + _c.sent(self, 'reporting_webhook') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'ControlMediaBuyRequest requires at least one of these field groups: name | paused | canceled | total_budget | daily_budget_cap | frequency_cap | budget_cap_timezone | budget_allocation | pacing | bidding | packages | reporting_webhook', + ) + if not ( + not (False or False or False or False or False or False) + ): + raise PydanticCustomError( + 'allOf', + 'ControlMediaBuyRequest violates a constraint its schema states outside its properties (media-buy/control-media-buy-request.json, root conjunct 0) over creative_assignments, creatives, end_time, invoice_recipient, new_packages, start_time', + {'fields': 'creative_assignments, creatives, end_time, invoice_recipient, new_packages, start_time'}, + ) + if not ( + (_c.sent(self, 'canceled') if _c.sent(self, 'cancellation_reason') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ControlMediaBuyRequest violates a constraint its schema states outside its properties (media-buy/control-media-buy-request.json, root conjunct 1) over canceled, cancellation_reason', + {'fields': 'canceled, cancellation_reason'}, + ) + if not ( + (not (_c.sent(self, 'name') or _c.sent(self, 'paused') or _c.sent(self, 'total_budget') or _c.supplied(self, 'daily_budget_cap') or _c.supplied(self, 'frequency_cap') or _c.supplied(self, 'budget_cap_timezone') or _c.sent(self, 'budget_allocation') or _c.sent(self, 'pacing') or _c.supplied(self, 'bidding') or _c.sent(self, 'packages') or _c.sent(self, 'reporting_webhook')) if _c.sent(self, 'canceled') else True) + ): + raise PydanticCustomError( + 'allOf', + 'ControlMediaBuyRequest violates a constraint its schema states outside its properties (media-buy/control-media-buy-request.json, root conjunct 2) over bidding, budget_allocation, budget_cap_timezone, canceled, daily_budget_cap, frequency_cap, name, pacing, packages, paused, reporting_webhook, total_budget', + {'fields': 'bidding, budget_allocation, budget_cap_timezone, canceled, daily_budget_cap, frequency_cap, name, pacing, packages, paused, reporting_webhook, total_budget'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/create_media_buy_request.py b/src/adcp/types/domains/media_buy/create_media_buy_request.py index 2752b58b2..77903804f 100644 --- a/src/adcp/types/domains/media_buy/create_media_buy_request.py +++ b/src/adcp/types/domains/media_buy/create_media_buy_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/create_media_buy_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,10 +8,10 @@ from adcp.types._str_enum import StrEnum from collections.abc import Sequence -from typing import Annotated, Literal +from typing import Annotated, Any, ClassVar, Literal, TypeAlias from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator +from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, WrapValidator, model_validator from ..core import account_ref, bidding_policy, brand_ref from ..core import budget_allocation as budget_allocation_1 @@ -30,9 +30,15 @@ from . import package_request +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError +from adcp.types._branch_dispatch import BranchDispatchMixin + + class Status(StrEnum): open = 'open' closed = 'closed' @@ -145,15 +151,29 @@ class ArtifactWebhook(AdCPBaseModel): class Opportunity(OpportunityContext): - status: Annotated[ - Literal['closed'], - Field( - description='Whether the planning cycle remains open. On calls after initial creation, omission means no status update and MUST NOT reopen or close the opportunity implicitly, except that successful create_media_buy proposal execution explicitly infers accepted closure.' - ), - ] = 'closed' + status: Annotated[Literal['closed'] | None, Field(description='Whether the planning cycle remains open. On calls after initial creation, omission means no status update and MUST NOT reopen or close the opportunity implicitly, except that successful create_media_buy proposal execution explicitly infers accepted closure.')] = None + + +class CreateMediaBuyRequest(BranchDispatchMixin, AdcpRequest, AdcpVersionEnvelope): + """What every branch of media-buy/create-media-buy-request.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``CreateMediaBuyRequestExplicitPackagesWithFixedAllocation`` + ``CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation`` + ``CreateMediaBuyRequestCommittedProposalExecution`` + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + CreateMediaBuyRequestBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ -class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): model_config = ConfigDict( extra='allow', ) @@ -320,19 +340,234 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'budget', 'budget_allocation', 'frequency_cap', 'min_spend_target', 'mode', 'packages', 'proposal_id', 'total_budget'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'account', 'brand', 'end_time', 'idempotency_key', 'start_time'}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + CreateMediaBuyRequestExplicitPackagesWithFixedAllocation, + CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation, + CreateMediaBuyRequestCommittedProposalExecution, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'anyOf' @model_validator(mode='after') def _require_schema_required_group(self) -> CreateMediaBuyRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('packages',), ('packages', 'total_budget', 'budget_allocation'), ('proposal_id', 'total_budget'),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'CreateMediaBuyRequest requires at least one of these field groups: packages | packages+total_budget+budget_allocation | proposal_id+total_budget', + # Compiled from media-buy/create-media-buy-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # Explicit packages with fixed allocation + ( + not (_c.sent(self, 'proposal_id')) + and (_c.descend(self, 'budget_allocation', lambda budget_allocation: _c.descend(budget_allocation, 'mode', lambda mode: mode == 'fixed') and _c.sent(budget_allocation, 'mode')) and _c.descend(self, 'packages', lambda packages: _c.every(packages, lambda item: _c.sent(item, 'budget') and not (_c.sent(item, 'min_spend_target'))))) + and _c.sent(self, 'packages') + ) + # Explicit packages with seller-optimized allocation + or ( + not (_c.sent(self, 'proposal_id')) + and _c.descend(self, 'budget_allocation', lambda budget_allocation: _c.descend(budget_allocation, 'mode', lambda mode: mode == 'seller_optimized') and _c.sent(budget_allocation, 'mode')) + and (_c.sent(self, 'packages') and _c.sent(self, 'total_budget') and _c.sent(self, 'budget_allocation')) + ) + # Committed proposal execution + or ( + not (_c.sent(self, 'packages') or _c.sent(self, 'budget_allocation') or _c.sent(self, 'frequency_cap')) + and (_c.sent(self, 'proposal_id') and _c.sent(self, 'total_budget')) + ) + ): + raise PydanticCustomError( + 'anyOf', + 'CreateMediaBuyRequest requires at least one of these field groups: Explicit packages with fixed allocation | Explicit packages with seller-optimized allocation | Committed proposal execution', + ) + if not ( + not (_c.supplied(self, 'proposal_id')) or _c.supplied(self, 'total_budget') + ): + raise PydanticCustomError( + 'allOf', + 'CreateMediaBuyRequest violates a constraint its schema states outside its properties (media-buy/create-media-buy-request.json, root conjunct 0) over proposal_id, total_budget', + {'fields': 'proposal_id, total_budget'}, + ) + if not ( + (_c.sent(self, 'proposal_id') if _c.sent(self, 'opportunity') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CreateMediaBuyRequest violates a constraint its schema states outside its properties (media-buy/create-media-buy-request.json, root conjunct 1) over opportunity, proposal_id', + {'fields': 'opportunity, proposal_id'}, + ) + if not ( + (_c.descend(self, 'packages', lambda packages: _c.every(packages, lambda item: not (_c.sent(item, 'bid_price') or (_c.descend(item, 'optimization_goals', lambda optimization_goals: _c.some(optimization_goals, lambda item: _c.descend(item, 'target', lambda target: _c.descend(target, 'kind', lambda kind: kind in ['cost_per', 'per_ad_spend']) and _c.supplied(target, 'kind')) and _c.supplied(item, 'target'))) and _c.sent(item, 'optimization_goals'))))) if _c.sent(self, 'bidding') else True) + ): + raise PydanticCustomError( + 'allOf', + 'CreateMediaBuyRequest violates a constraint its schema states outside its properties (media-buy/create-media-buy-request.json, root conjunct 2) over bid_price, bidding, kind, optimization_goals, packages, target', + {'fields': 'bid_price, bidding, kind, optimization_goals, packages, target'}, + ) + return self + +class CreateMediaBuyRequestExplicitPackagesWithFixedAllocation(CreateMediaBuyRequest): + """Explicit packages with fixed allocation. + + One branch of media-buy/create-media-buy-request.json, stated as a shape: anyOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + proposal_id: None = None + packages: Annotated[Sequence[package_request.PackageRequest], Field(description='Array of package configurations. Required when not using proposal_id. Mutually exclusive: provide packages or proposal_id, not both. Fixed allocation requires budget on every package. Seller-optimized allocation permits package budget to be omitted or to act as a hard cap. When executing a proposal, omit packages; the seller derives them from the committed proposal.', min_length=1)] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'proposal_id'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreateMediaBuyRequestExplicitPackagesWithFixedAllocation: + # Compiled from media-buy/create-media-buy-request.json anyOf/0: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + _c.descend(self, 'budget_allocation', lambda budget_allocation: _c.descend(budget_allocation, 'mode', lambda mode: mode == 'fixed') and _c.sent(budget_allocation, 'mode')) and _c.descend(self, 'packages', lambda packages: _c.every(packages, lambda item: _c.sent(item, 'budget') and not (_c.sent(item, 'min_spend_target')))) and (not (_c.supplied(self, 'proposal_id')) or _c.supplied(self, 'total_budget')) and (_c.sent(self, 'proposal_id') if _c.sent(self, 'opportunity') else True) and (_c.descend(self, 'packages', lambda packages: _c.every(packages, lambda item: not (_c.sent(item, 'bid_price') or (_c.descend(item, 'optimization_goals', lambda optimization_goals: _c.some(optimization_goals, lambda item: _c.descend(item, 'target', lambda target: _c.descend(target, 'kind', lambda kind: kind in ['cost_per', 'per_ad_spend']) and _c.supplied(target, 'kind')) and _c.supplied(item, 'target'))) and _c.sent(item, 'optimization_goals'))))) if _c.sent(self, 'bidding') else True) + ): + raise PydanticCustomError( + 'anyOf', + 'CreateMediaBuyRequestExplicitPackagesWithFixedAllocation violates what media-buy/create-media-buy-request.json states for its anyOf/0 branch beyond the fields it requires and forbids', + ) + return self + + +class CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation(CreateMediaBuyRequest): + """Explicit packages with seller-optimized allocation. + + One branch of media-buy/create-media-buy-request.json, stated as a shape: anyOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + proposal_id: None = None + budget_allocation: Annotated[budget_allocation_1.BudgetAllocation, Field(description='How budget is allocated across explicit packages. Omission means fixed allocation (legacy-compatible). Buyer agents SHOULD send budget_allocation explicitly ({mode: "fixed"} or seller_optimized) rather than rely on omission, and when the principal\'s instruction does not determine whether the budget is one shared pool across packages or split per package, SHOULD ask the principal rather than guess. seller_optimized requires advertised media_buy.features.seller_optimized_budget; inside it, package budget caps, min_spend_target, and package pacing each require their own advertised sub-capability (seller_optimized_package_budgets, seller_optimized_min_spend_targets, seller_optimized_package_pacing), otherwise the request is rejected with UNSUPPORTED_FEATURE before any over-subscription validation. In proposal mode the committed proposal supplies this configuration and callers MUST omit it here.')] + packages: Annotated[Sequence[package_request.PackageRequest], Field(description='Array of package configurations. Required when not using proposal_id. Mutually exclusive: provide packages or proposal_id, not both. Fixed allocation requires budget on every package. Seller-optimized allocation permits package budget to be omitted or to act as a hard cap. When executing a proposal, omit packages; the seller derives them from the committed proposal.', min_length=1)] + total_budget: Annotated[TotalBudget, Field(description='Hard aggregate lifetime budget for the media buy. Required when executing a proposal and for seller-optimized explicit packages. Optional in fixed explicit-package mode; when present there, amount MUST equal the sum of package budgets. For a fixed proposal, the publisher applies allocation percentages to this amount. For a seller-optimized proposal or explicit buy, packages draw dynamically from this shared total.')] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'proposal_id'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) + ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation: + # Compiled from media-buy/create-media-buy-request.json anyOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + _c.descend(self, 'budget_allocation', lambda budget_allocation: _c.descend(budget_allocation, 'mode', lambda mode: mode == 'seller_optimized') and _c.sent(budget_allocation, 'mode')) and (not (_c.supplied(self, 'proposal_id')) or _c.supplied(self, 'total_budget')) and (_c.sent(self, 'proposal_id') if _c.sent(self, 'opportunity') else True) and (_c.descend(self, 'packages', lambda packages: _c.every(packages, lambda item: not (_c.sent(item, 'bid_price') or (_c.descend(item, 'optimization_goals', lambda optimization_goals: _c.some(optimization_goals, lambda item: _c.descend(item, 'target', lambda target: _c.descend(target, 'kind', lambda kind: kind in ['cost_per', 'per_ad_spend']) and _c.supplied(target, 'kind')) and _c.supplied(item, 'target'))) and _c.sent(item, 'optimization_goals'))))) if _c.sent(self, 'bidding') else True) + ): + raise PydanticCustomError( + 'anyOf', + 'CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation violates what media-buy/create-media-buy-request.json states for its anyOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +class CreateMediaBuyRequestCommittedProposalExecution(CreateMediaBuyRequest): + """Committed proposal execution. + + One branch of media-buy/create-media-buy-request.json, stated as a shape: anyOf/2 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + budget_allocation: None = None + frequency_cap: None = None + packages: None = None + proposal_id: Annotated[str, Field(description="ID of the exact committed proposal snapshot to execute. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Mutually exclusive: provide packages or proposal_id, not both. AdCP 3.2 request_proposals and ordinary refine_proposals revisions issue drafts; refine_proposals action finalize creates the executable committed hold. Sellers reject draft, declined, or previously executed snapshots, while exact retries with the original idempotency key replay historical success. Changed commercial terms are issued under a new proposal_id, so no separate proposal version is required.")] + total_budget: Annotated[TotalBudget, Field(description='Hard aggregate lifetime budget for the media buy. Required when executing a proposal and for seller-optimized explicit packages. Optional in fixed explicit-package mode; when present there, amount MUST equal the sum of package budgets. For a fixed proposal, the publisher applies allocation percentages to this amount. For a seller-optimized proposal or explicit buy, packages draw dynamically from this shared total.')] + _ABSENT: ClassVar[frozenset[str]] = frozenset({'budget_allocation', 'frequency_cap', 'packages'}) + _ABSENT_NULLABLE: ClassVar[frozenset[str]] = frozenset({}) + + @model_validator(mode='before') + @classmethod + def _refuse_keys_this_shape_forbids(cls, data: Any) -> Any: + # The fields this branch forbids are typed None above, which is what + # makes the combination a type error. A raw document still needs + # this: extra='allow' files an undeclared key under extras and + # accepts, and a declared one would arrive as a None the branch does + # not forbid at a nullable position. + return _c.absent( + data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE) + ) + @model_validator(mode='after') + def _require_schema_required_group(self) -> CreateMediaBuyRequestCommittedProposalExecution: + # Compiled from media-buy/create-media-buy-request.json anyOf/2: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + (not (_c.supplied(self, 'proposal_id')) or _c.supplied(self, 'total_budget')) and (_c.sent(self, 'proposal_id') if _c.sent(self, 'opportunity') else True) and (_c.descend(self, 'packages', lambda packages: _c.every(packages, lambda item: not (_c.sent(item, 'bid_price') or (_c.descend(item, 'optimization_goals', lambda optimization_goals: _c.some(optimization_goals, lambda item: _c.descend(item, 'target', lambda target: _c.descend(target, 'kind', lambda kind: kind in ['cost_per', 'per_ad_spend']) and _c.supplied(target, 'kind')) and _c.supplied(item, 'target'))) and _c.sent(item, 'optimization_goals'))))) if _c.sent(self, 'bidding') else True) + ): + raise PydanticCustomError( + 'anyOf', + 'CreateMediaBuyRequestCommittedProposalExecution violates what media-buy/create-media-buy-request.json states for its anyOf/2 branch beyond the fields it requires and forbids', + ) + return self + + +#: media-buy/create-media-buy-request.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate CreateMediaBuyRequest to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +CreateMediaBuyRequestBranches: TypeAlias = Annotated[ + CreateMediaBuyRequestExplicitPackagesWithFixedAllocation | CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation | CreateMediaBuyRequestCommittedProposalExecution, + WrapValidator( + _c.name_the_combinator('anyOf', CreateMediaBuyRequest._branch_models(), CreateMediaBuyRequest._ROOT_REQUIRED) + ), +] + +# CreateMediaBuyRequest is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +CreateMediaBuyRequest.model_rebuild(force=True) diff --git a/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py b/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py index 482f387ee..7d5a580fa 100644 --- a/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py +++ b/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buy_delivery_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -12,7 +12,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, RootModel, StrictBool +from pydantic import ConfigDict, Field, RootModel, StrictBool, model_validator from ..core import account_ref from ..core import context as context_1 @@ -33,6 +33,12 @@ from ..enums import sort_metric +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class AttributionWindow(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -546,6 +552,32 @@ class GetMediaBuyDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetMediaBuyDeliveryRequest: + # Compiled from media-buy/get-media-buy-delivery-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (not (_c.sent(self, 'media_buy_ids') or _c.sent(self, 'start_date') or _c.sent(self, 'end_date') or _c.sent(self, 'status_filter') or _c.sent(self, 'requested_metrics') or _c.sent(self, 'reporting_dimensions') or _c.sent(self, 'attribution_window') or _c.sent(self, 'include_package_daily_breakdown') or _c.sent(self, 'time_granularity') or _c.sent(self, 'include_window_breakdown')) if _c.sent(self, 'reporting_revision_id') else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetMediaBuyDeliveryRequest violates a constraint its schema states outside its properties (media-buy/get-media-buy-delivery-request.json, root conjunct 0) over attribution_window, end_date, include_package_daily_breakdown, include_window_breakdown, media_buy_ids, reporting_dimensions, reporting_revision_id, requested_metrics, start_date, status_filter, time_granularity', + {'fields': 'attribution_window, end_date, include_package_daily_breakdown, include_window_breakdown, media_buy_ids, reporting_dimensions, reporting_revision_id, requested_metrics, start_date, status_filter, time_granularity'}, + ) + if not ( + (_c.sent(self, 'reporting_revision_id') if _c.sent(self, 'pagination') else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetMediaBuyDeliveryRequest violates a constraint its schema states outside its properties (media-buy/get-media-buy-delivery-request.json, root conjunct 1) over pagination, reporting_revision_id', + {'fields': 'pagination, reporting_revision_id'}, + ) + return self @model_validator(mode='after') def _validate_delivery_selector_mode(self) -> GetMediaBuyDeliveryRequest: diff --git a/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py b/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py index 624db149a..943054be1 100644 --- a/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py +++ b/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buy_delivery_response.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -11,7 +11,7 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import attribution_window as attribution_window_1 from ..core import ( @@ -56,6 +56,12 @@ from ..enums import sort_direction, sort_metric +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class NotificationType(StrEnum): scheduled = 'scheduled' final = 'final' @@ -1234,3 +1240,29 @@ class GetMediaBuyDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetMediaBuyDeliveryResponse: + # Compiled from media-buy/get-media-buy-delivery-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'currency') if _c.sent(self, 'aggregated_totals') else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetMediaBuyDeliveryResponse violates a constraint its schema states outside its properties (media-buy/get-media-buy-delivery-response.json, root conjunct 0) over aggregated_totals, currency', + {'fields': 'aggregated_totals, currency'}, + ) + if not ( + ((_c.descend(self, 'pagination', lambda pagination: _c.sent(pagination, 'total_count')) and (_c.sent(self, 'reporting_revision') and _c.sent(self, 'reporting_revision_binding') and _c.sent(self, 'reporting_rows') and _c.sent(self, 'pagination'))) if (_c.sent(self, 'reporting_revision') or _c.sent(self, 'reporting_revision_binding') or _c.sent(self, 'reporting_rows')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetMediaBuyDeliveryResponse violates a constraint its schema states outside its properties (media-buy/get-media-buy-delivery-response.json, root conjunct 1) over pagination, reporting_revision, reporting_revision_binding, reporting_rows, total_count', + {'fields': 'pagination, reporting_revision, reporting_revision_binding, reporting_rows, total_count'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py b/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py index 32193d55f..6c89006e2 100644 --- a/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py +++ b/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import error from ..core import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class GetProductsSubmitted(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -48,3 +54,21 @@ class GetProductsSubmitted(AdCPBaseModel): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetProductsSubmitted: + # Compiled from media-buy/get-products-async-response-submitted.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'products') or _c.supplied(self, 'proposals') or _c.supplied(self, 'result')) + ): + raise PydanticCustomError( + 'allOf', + 'GetProductsSubmitted violates a constraint its schema states outside its properties (media-buy/get-products-async-response-submitted.json, root conjunct 0) over products, proposals, result', + {'fields': 'products, proposals, result'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/get_products_rejected.py b/src/adcp/types/domains/media_buy/get_products_rejected.py index 4b1326afe..b67bb5593 100644 --- a/src/adcp/types/domains/media_buy/get_products_rejected.py +++ b/src/adcp/types/domains/media_buy/get_products_rejected.py @@ -1,14 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_rejected.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarStr - from typing import Annotated, Literal -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import ext as ext_1 @@ -16,6 +14,13 @@ from ..core.version_envelope import AdcpVersionEnvelope +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Suggestion(ScalarStr): __slots__ = () _constraints = {'max_length': 1000, 'min_length': 1} @@ -49,3 +54,21 @@ class GetProductsRejected(AdcpVersionEnvelope, ProtocolEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetProductsRejected: + # Compiled from media-buy/get-products-rejected.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'products') or _c.supplied(self, 'proposals') or _c.supplied(self, 'errors') or _c.supplied(self, 'adcp_error') or _c.supplied(self, 'incomplete') or _c.supplied(self, 'cache_scope') or _c.supplied(self, 'filter_diagnostics') or _c.supplied(self, 'refinement_applied') or _c.supplied(self, 'unchanged') or _c.supplied(self, 'wholesale_feed_version') or _c.supplied(self, 'pricing_version') or _c.supplied(self, 'pagination')) + ): + raise PydanticCustomError( + 'allOf', + 'GetProductsRejected violates a constraint its schema states outside its properties (media-buy/get-products-rejected.json, root conjunct 0) over adcp_error, cache_scope, errors, filter_diagnostics, incomplete, pagination, pricing_version, products, proposals, refinement_applied, unchanged, wholesale_feed_version', + {'fields': 'adcp_error, cache_scope, errors, filter_diagnostics, incomplete, pagination, pricing_version, products, proposals, refinement_applied, unchanged, wholesale_feed_version'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/get_products_request.py b/src/adcp/types/domains/media_buy/get_products_request.py index ad6425dd1..e2023452e 100644 --- a/src/adcp/types/domains/media_buy/get_products_request.py +++ b/src/adcp/types/domains/media_buy/get_products_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import account_ref, brand_ref from ..core import catalog as catalog_1 @@ -32,6 +32,12 @@ from . import product_fields +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class BuyingMode(StrEnum): brief = 'brief' wholesale = 'wholesale' @@ -346,3 +352,38 @@ class GetProductsRequest(AdcpRequest, AdcpVersionEnvelope): ), ] = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetProductsRequest: + # Compiled from media-buy/get-products-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'catalog')) or _c.supplied(self, 'brand') + ): + raise PydanticCustomError( + 'allOf', + 'GetProductsRequest violates a constraint its schema states outside its properties (media-buy/get-products-request.json, root conjunct 0) over brand, catalog', + {'fields': 'brand, catalog'}, + ) + if not ( + not (_c.supplied(self, 'if_pricing_version')) + or _c.supplied(self, 'if_wholesale_feed_version') + ): + raise PydanticCustomError( + 'allOf', + 'GetProductsRequest violates a constraint its schema states outside its properties (media-buy/get-products-request.json, root conjunct 1) over if_pricing_version, if_wholesale_feed_version', + {'fields': 'if_pricing_version, if_wholesale_feed_version'}, + ) + if not ( + (_c.descend(self, 'buying_mode', lambda buying_mode: buying_mode == 'wholesale') if (_c.sent(self, 'if_wholesale_feed_version') or _c.sent(self, 'if_pricing_version')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetProductsRequest violates a constraint its schema states outside its properties (media-buy/get-products-request.json, root conjunct 2) over buying_mode, if_pricing_version, if_wholesale_feed_version', + {'fields': 'buying_mode, if_pricing_version, if_wholesale_feed_version'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/get_products_response.py b/src/adcp/types/domains/media_buy/get_products_response.py index 06e34d9e9..d2c9d34b2 100644 --- a/src/adcp/types/domains/media_buy/get_products_response.py +++ b/src/adcp/types/domains/media_buy/get_products_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,8 +10,7 @@ from typing import Annotated, Any, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, StrictBool, StringConstraints +from pydantic import ConfigDict, Field, StrictBool, StringConstraints, model_validator from ..core import context as context_1 from ..core import duration, error @@ -22,6 +21,13 @@ from . import get_products_targeting_resolution +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Extensions(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -349,3 +355,37 @@ class GetProductsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetProductsResponse: + # Compiled from media-buy/get-products-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((not (_c.sent(self, 'products')) and (_c.sent(self, 'wholesale_feed_version') and _c.sent(self, 'cache_scope'))) if _c.sent(self, 'unchanged') else ((not (_c.sent(self, 'products') or _c.sent(self, 'proposals') or _c.sent(self, 'errors') or _c.supplied(self, 'adcp_error') or _c.sent(self, 'incomplete') or _c.sent(self, 'cache_scope') or _c.sent(self, 'filter_diagnostics') or _c.sent(self, 'refinement_applied') or _c.sent(self, 'unchanged') or _c.sent(self, 'wholesale_feed_version') or _c.sent(self, 'pricing_version') or _c.sent(self, 'pagination')) and (_c.descend(self, 'status', lambda status: status == 'rejected' and _c.json_type(status, 'string')) and _c.descend(self, 'suggestions', lambda suggestions: _c.length(suggestions, minimum=1))) and (_c.supplied(self, 'status') and _c.sent(self, 'reason'))) if (_c.descend(self, 'status', lambda status: status == 'rejected') and _c.supplied(self, 'status')) else (_c.sent(self, 'errors') if (_c.descend(self, 'status', lambda status: status == 'failed') and _c.supplied(self, 'status')) else (_c.sent(self, 'products') and _c.sent(self, 'cache_scope'))))) + ): + raise PydanticCustomError( + 'allOf', + 'GetProductsResponse violates a constraint its schema states outside its properties (media-buy/get-products-response.json, root conjunct 0) over adcp_error, cache_scope, errors, filter_diagnostics, incomplete, pagination, pricing_version, products, proposals, reason, refinement_applied, status, suggestions, unchanged, wholesale_feed_version', + {'fields': 'adcp_error, cache_scope, errors, filter_diagnostics, incomplete, pagination, pricing_version, products, proposals, reason, refinement_applied, status, suggestions, unchanged, wholesale_feed_version'}, + ) + if not ( + (not (_c.sent(self, 'reason') or _c.sent(self, 'suggestions')) if (_c.descend(self, 'status', lambda status: not (status == 'rejected')) and _c.supplied(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetProductsResponse violates a constraint its schema states outside its properties (media-buy/get-products-response.json, root conjunct 1) over reason, status, suggestions', + {'fields': 'reason, status, suggestions'}, + ) + if not ( + ((not (_c.sent(self, 'products') or _c.sent(self, 'proposals') or _c.sent(self, 'errors') or _c.supplied(self, 'adcp_error') or _c.sent(self, 'incomplete') or _c.sent(self, 'cache_scope') or _c.sent(self, 'filter_diagnostics') or _c.sent(self, 'refinement_applied') or _c.sent(self, 'unchanged') or _c.sent(self, 'wholesale_feed_version') or _c.sent(self, 'pricing_version') or _c.sent(self, 'pagination')) and (_c.descend(self, 'status', lambda status: status == 'rejected' and _c.json_type(status, 'string')) and _c.descend(self, 'suggestions', lambda suggestions: _c.length(suggestions, minimum=1))) and (_c.supplied(self, 'status') and _c.sent(self, 'reason'))) if (_c.descend(self, 'status', lambda status: status == 'rejected') and _c.supplied(self, 'status')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetProductsResponse violates a constraint its schema states outside its properties (media-buy/get-products-response.json, root conjunct 2) over adcp_error, cache_scope, errors, filter_diagnostics, incomplete, pagination, pricing_version, products, proposals, reason, refinement_applied, status, suggestions, unchanged, wholesale_feed_version', + {'fields': 'adcp_error, cache_scope, errors, filter_diagnostics, incomplete, pagination, pricing_version, products, proposals, reason, refinement_applied, status, suggestions, unchanged, wholesale_feed_version'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/get_reporting_status_request.py b/src/adcp/types/domains/media_buy/get_reporting_status_request.py index 5c1c57281..53ba52dcf 100644 --- a/src/adcp/types/domains/media_buy/get_reporting_status_request.py +++ b/src/adcp/types/domains/media_buy/get_reporting_status_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_reporting_status_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,8 +10,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..core import canonical_account_ref from ..core import context as context_1 @@ -21,6 +20,13 @@ from ..enums import reporting_finality, reporting_health +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class DeliveryConfigId(ScalarStr): __slots__ = () _constraints = {'max_length': 64, 'min_length': 1, 'pattern': '^[A-Za-z0-9_.:-]{1,64}$'} @@ -110,3 +116,37 @@ class GetReportingStatusRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetReportingStatusRequest: + # Compiled from media-buy/get-reporting-status-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (not (_c.sent(self, 'reporting_revision_id') or _c.sent(self, 'pagination') or _c.sent(self, 'health') or _c.sent(self, 'changes_after')) if (_c.descend(self, 'view', lambda view: view == 'summary') and _c.sent(self, 'view')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetReportingStatusRequest violates a constraint its schema states outside its properties (media-buy/get-reporting-status-request.json, root conjunct 0) over changes_after, health, pagination, reporting_revision_id, view', + {'fields': 'changes_after, health, pagination, reporting_revision_id, view'}, + ) + if not ( + (not (_c.sent(self, 'reporting_revision_id')) if (_c.descend(self, 'view', lambda view: view == 'periods') and _c.sent(self, 'view')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetReportingStatusRequest violates a constraint its schema states outside its properties (media-buy/get-reporting-status-request.json, root conjunct 1) over reporting_revision_id, view', + {'fields': 'reporting_revision_id, view'}, + ) + if not ( + ((not (_c.sent(self, 'media_buy_ids') or _c.sent(self, 'delivery_config_ids') or _c.sent(self, 'feed_purposes') or _c.sent(self, 'period') or _c.sent(self, 'health') or _c.sent(self, 'finality') or _c.sent(self, 'changes_after')) and _c.sent(self, 'reporting_revision_id')) if (_c.descend(self, 'view', lambda view: view == 'revision') and _c.sent(self, 'view')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetReportingStatusRequest violates a constraint its schema states outside its properties (media-buy/get-reporting-status-request.json, root conjunct 2) over changes_after, delivery_config_ids, feed_purposes, finality, health, media_buy_ids, period, reporting_revision_id, view', + {'fields': 'changes_after, delivery_config_ids, feed_purposes, finality, health, media_buy_ids, period, reporting_revision_id, view'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/get_reporting_status_response.py b/src/adcp/types/domains/media_buy/get_reporting_status_response.py index 299fce0b3..52c7f205f 100644 --- a/src/adcp/types/domains/media_buy/get_reporting_status_response.py +++ b/src/adcp/types/domains/media_buy/get_reporting_status_response.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/get_reporting_status_response.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpResponse, SchemaInt from adcp.types._str_enum import StrEnum -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator @@ -34,6 +34,9 @@ from . import get_reporting_status_request +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -205,19 +208,78 @@ class GetReportingStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None status: Status + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'account_id', 'adcp_error', 'adcp_major_version', 'adcp_version', 'adjustment_receipts', 'adjustments', 'buyer_reason', 'changes_checkpoint', 'code', 'consumer_statuses', 'context', 'context_id', 'coverage', 'data_through', 'details', 'discriminator', 'errors', 'failure_kind', 'field', 'has_more', 'health', 'issues', 'keyword', 'ledger_as_of', 'ledger_snapshot_id', 'materializations', 'message', 'next_expected_at', 'obligation_counts', 'pagination', 'periods', 'pointer', 'property_name', 'receipts', 'recovery', 'replayed', 'retry_after', 'revision', 'revisions', 'schemaPath', 'schema_id', 'scope', 'sdk_id', 'source', 'status', 'suggestion', 'timestamp', 'total_count', 'value', 'view'}) @model_validator(mode='after') def _require_schema_required_group(self) -> GetReportingStatusResponse: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('status',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'oneOf', - 'GetReportingStatusResponse requires at least one of these field groups: status', - ) + # Compiled from media-buy/get-reporting-status-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # Successful lookup + ( + [not (_c.sent(self, 'changes_checkpoint') or _c.sent(self, 'periods') or _c.sent(self, 'revisions') or _c.sent(self, 'adjustments') or _c.sent(self, 'consumer_statuses') or _c.sent(self, 'adjustment_receipts') or _c.sent(self, 'pagination') or _c.sent(self, 'revision') or _c.sent(self, 'materializations') or _c.sent(self, 'receipts')) and _c.descend(self, 'view', lambda view: view == 'summary' and _c.json_type(view, 'string')) and (_c.sent(self, 'view') and _c.sent(self, 'ledger_snapshot_id') and _c.sent(self, 'ledger_as_of') and _c.sent(self, 'account_id') and _c.sent(self, 'scope') and _c.sent(self, 'health') and _c.sent(self, 'coverage') and _c.supplied(self, 'data_through') and _c.sent(self, 'obligation_counts') and _c.sent(self, 'issues')), (not (_c.sent(self, 'next_expected_at')) if (_c.descend(self, 'health', lambda health: health == 'complete') and _c.sent(self, 'health')) else True) and not (_c.sent(self, 'revision')) and (_c.descend(self, 'pagination', lambda pagination: (_c.sent(pagination, 'has_more') and _c.sent(pagination, 'total_count')) and _c.json_type(pagination, 'object')) and _c.descend(self, 'view', lambda view: view == 'periods' and _c.json_type(view, 'string'))) and (_c.sent(self, 'view') and _c.sent(self, 'ledger_snapshot_id') and _c.sent(self, 'ledger_as_of') and _c.sent(self, 'account_id') and _c.sent(self, 'scope') and _c.sent(self, 'periods') and _c.sent(self, 'revisions') and _c.sent(self, 'materializations') and _c.sent(self, 'receipts') and _c.sent(self, 'pagination')), not (_c.sent(self, 'changes_checkpoint') or _c.sent(self, 'scope') or _c.sent(self, 'health') or _c.sent(self, 'periods') or _c.sent(self, 'revisions')) and (_c.descend(self, 'pagination', lambda pagination: (_c.sent(pagination, 'has_more') and _c.sent(pagination, 'total_count')) and _c.json_type(pagination, 'object')) and _c.descend(self, 'view', lambda view: view == 'revision' and _c.json_type(view, 'string'))) and (_c.sent(self, 'view') and _c.sent(self, 'ledger_snapshot_id') and _c.sent(self, 'ledger_as_of') and _c.sent(self, 'account_id') and _c.sent(self, 'revision') and _c.sent(self, 'materializations') and _c.sent(self, 'receipts') and _c.sent(self, 'pagination'))].count(True) == 1 + and _c.descend(self, 'status', lambda status: status == 'completed' and _c.json_type(status, 'string')) + and _c.supplied(self, 'status') + ) + # Failed lookup + or ( + [_c.only(self, ['adcp_error', 'adcp_major_version', 'adcp_version', 'context', 'context_id', 'errors', 'failure_kind', 'message', 'replayed', 'status', 'timestamp', 'view']) and (_c.descend(self, 'adcp_error', lambda adcp_error: _c.only(adcp_error, ['code', 'message']) and (_c.descend(adcp_error, 'code', lambda code: code == 'NOT_FOUND') and _c.descend(adcp_error, 'message', lambda message: message == 'Reporting status resource is unavailable.')) and (_c.supplied(adcp_error, 'code') and _c.supplied(adcp_error, 'message')) and _c.json_type(adcp_error, 'object')) and _c.descend(self, 'adcp_major_version', lambda adcp_major_version: _c.json_type(adcp_major_version, 'integer')) and _c.descend(self, 'adcp_version', lambda adcp_version: _c.json_type(adcp_version, 'string')) and _c.descend(self, 'context_id', lambda context_id: _c.json_type(context_id, 'string')) and _c.descend(self, 'errors', lambda errors: _c.every(errors, lambda item: _c.only(item, ['code', 'message']) and (_c.descend(item, 'code', lambda code: code == 'NOT_FOUND') and _c.descend(item, 'message', lambda message: message == 'Reporting status resource is unavailable.')) and (_c.sent(item, 'code') and _c.sent(item, 'message')) and _c.json_type(item, 'object')) and _c.length(errors, maximum=1) and _c.length(errors, minimum=1)) and _c.descend(self, 'failure_kind', lambda failure_kind: failure_kind == 'lookup_unavailable' and _c.json_type(failure_kind, 'string')) and _c.descend(self, 'message', lambda message: message == 'Reporting status resource is unavailable.') and _c.descend(self, 'replayed', lambda replayed: _c.json_type(replayed, 'boolean')) and _c.descend(self, 'status', lambda status: status == 'failed' and _c.json_type(status, 'string')) and _c.descend(self, 'timestamp', lambda timestamp: _c.json_type(timestamp, 'string')) and _c.descend(self, 'view', lambda view: view in ['summary', 'periods', 'revision'])) and (_c.supplied(self, 'status') and _c.sent(self, 'view') and _c.supplied(self, 'failure_kind') and _c.sent(self, 'errors')), _c.only(self, ['adcp_error', 'adcp_major_version', 'adcp_version', 'context', 'context_id', 'errors', 'failure_kind', 'message', 'replayed', 'status', 'timestamp', 'view']) and (_c.descend(self, 'adcp_error', lambda adcp_error: (not (_c.supplied(adcp_error, 'buyer_reason')) or _c.supplied(adcp_error, 'recovery')) and (_c.descend(adcp_error, 'buyer_reason', lambda buyer_reason: (_c.descend(buyer_reason, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(buyer_reason, 'message', lambda message: _c.length(message, minimum=1) and _c.json_type(message, 'string'))) and (_c.supplied(buyer_reason, 'code') and _c.supplied(buyer_reason, 'message')) and _c.json_type(buyer_reason, 'object')) and _c.descend(adcp_error, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(adcp_error, 'details', lambda details: _c.json_type(details, 'object')) and _c.descend(adcp_error, 'field', lambda field: _c.json_type(field, 'string')) and _c.descend(adcp_error, 'issues', lambda issues: _c.every(issues, lambda item: (_c.descend(item, 'discriminator', lambda discriminator: _c.every(discriminator, lambda item: _c.only(item, ['property_name', 'value']) and (_c.descend(item, 'property_name', lambda property_name: _c.json_type(property_name, 'string')) and _c.descend(item, 'value', lambda value: _c.json_type(value, 'string', 'number', 'boolean', 'null'))) and (_c.supplied(item, 'property_name') and _c.supplied(item, 'value')) and _c.json_type(item, 'object')) and _c.json_type(discriminator, 'array')) and _c.descend(item, 'keyword', lambda keyword: _c.json_type(keyword, 'string')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'pointer', lambda pointer: _c.json_type(pointer, 'string')) and _c.descend(item, 'schemaPath', lambda schemaPath: _c.json_type(schemaPath, 'string')) and _c.descend(item, 'schema_id', lambda schema_id: _c.json_type(schema_id, 'string'))) and (_c.supplied(item, 'pointer') and _c.supplied(item, 'message') and _c.supplied(item, 'keyword')) and _c.json_type(item, 'object')) and _c.json_type(issues, 'array')) and _c.descend(adcp_error, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(adcp_error, 'recovery', lambda recovery: recovery in ['transient', 'correctable', 'terminal'] and _c.json_type(recovery, 'string')) and _c.descend(adcp_error, 'retry_after', lambda retry_after: _c.within(retry_after, le=3600) and _c.within(retry_after, ge=1) and _c.json_type(retry_after, 'number')) and _c.descend(adcp_error, 'sdk_id', lambda sdk_id: _c.json_type(sdk_id, 'string')) and _c.descend(adcp_error, 'source', lambda source: source in ['producer', 'sdk'] and _c.json_type(source, 'string')) and _c.descend(adcp_error, 'suggestion', lambda suggestion: _c.json_type(suggestion, 'string'))) and (_c.supplied(adcp_error, 'code') and _c.supplied(adcp_error, 'message')) and _c.json_type(adcp_error, 'object')) and _c.descend(self, 'adcp_major_version', lambda adcp_major_version: _c.json_type(adcp_major_version, 'integer')) and _c.descend(self, 'adcp_version', lambda adcp_version: _c.json_type(adcp_version, 'string')) and _c.descend(self, 'context_id', lambda context_id: _c.json_type(context_id, 'string')) and _c.descend(self, 'errors', lambda errors: _c.length(errors, minimum=1)) and _c.descend(self, 'failure_kind', lambda failure_kind: failure_kind == 'operational' and _c.json_type(failure_kind, 'string')) and _c.descend(self, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(self, 'replayed', lambda replayed: _c.json_type(replayed, 'boolean')) and _c.descend(self, 'status', lambda status: status == 'failed' and _c.json_type(status, 'string')) and _c.descend(self, 'timestamp', lambda timestamp: _c.json_type(timestamp, 'string')) and _c.descend(self, 'view', lambda view: view in ['summary', 'periods', 'revision'])) and (_c.supplied(self, 'status') and _c.sent(self, 'view') and _c.supplied(self, 'failure_kind') and _c.sent(self, 'errors'))].count(True) == 1 + and _c.descend(self, 'status', lambda status: status == 'failed' and _c.json_type(status, 'string')) + and _c.supplied(self, 'status') + ) + ): + raise PydanticCustomError( + 'oneOf', + 'GetReportingStatusResponse requires at least one of these field groups: Successful lookup | Failed lookup', + ) + if [([not (_c.sent(self, 'changes_checkpoint') or _c.sent(self, 'periods') or _c.sent(self, 'revisions') or _c.sent(self, 'adjustments') or _c.sent(self, 'consumer_statuses') or _c.sent(self, 'adjustment_receipts') or _c.sent(self, 'pagination') or _c.sent(self, 'revision') or _c.sent(self, 'materializations') or _c.sent(self, 'receipts')) and _c.descend(self, 'view', lambda view: view == 'summary' and _c.json_type(view, 'string')) and (_c.sent(self, 'view') and _c.sent(self, 'ledger_snapshot_id') and _c.sent(self, 'ledger_as_of') and _c.sent(self, 'account_id') and _c.sent(self, 'scope') and _c.sent(self, 'health') and _c.sent(self, 'coverage') and _c.supplied(self, 'data_through') and _c.sent(self, 'obligation_counts') and _c.sent(self, 'issues')), (not (_c.sent(self, 'next_expected_at')) if (_c.descend(self, 'health', lambda health: health == 'complete') and _c.sent(self, 'health')) else True) and not (_c.sent(self, 'revision')) and (_c.descend(self, 'pagination', lambda pagination: (_c.sent(pagination, 'has_more') and _c.sent(pagination, 'total_count')) and _c.json_type(pagination, 'object')) and _c.descend(self, 'view', lambda view: view == 'periods' and _c.json_type(view, 'string'))) and (_c.sent(self, 'view') and _c.sent(self, 'ledger_snapshot_id') and _c.sent(self, 'ledger_as_of') and _c.sent(self, 'account_id') and _c.sent(self, 'scope') and _c.sent(self, 'periods') and _c.sent(self, 'revisions') and _c.sent(self, 'materializations') and _c.sent(self, 'receipts') and _c.sent(self, 'pagination')), not (_c.sent(self, 'changes_checkpoint') or _c.sent(self, 'scope') or _c.sent(self, 'health') or _c.sent(self, 'periods') or _c.sent(self, 'revisions')) and (_c.descend(self, 'pagination', lambda pagination: (_c.sent(pagination, 'has_more') and _c.sent(pagination, 'total_count')) and _c.json_type(pagination, 'object')) and _c.descend(self, 'view', lambda view: view == 'revision' and _c.json_type(view, 'string'))) and (_c.sent(self, 'view') and _c.sent(self, 'ledger_snapshot_id') and _c.sent(self, 'ledger_as_of') and _c.sent(self, 'account_id') and _c.sent(self, 'revision') and _c.sent(self, 'materializations') and _c.sent(self, 'receipts') and _c.sent(self, 'pagination'))].count(True) == 1 and _c.descend(self, 'status', lambda status: status == 'completed' and _c.json_type(status, 'string')) and _c.supplied(self, 'status')), ([_c.only(self, ['adcp_error', 'adcp_major_version', 'adcp_version', 'context', 'context_id', 'errors', 'failure_kind', 'message', 'replayed', 'status', 'timestamp', 'view']) and (_c.descend(self, 'adcp_error', lambda adcp_error: _c.only(adcp_error, ['code', 'message']) and (_c.descend(adcp_error, 'code', lambda code: code == 'NOT_FOUND') and _c.descend(adcp_error, 'message', lambda message: message == 'Reporting status resource is unavailable.')) and (_c.supplied(adcp_error, 'code') and _c.supplied(adcp_error, 'message')) and _c.json_type(adcp_error, 'object')) and _c.descend(self, 'adcp_major_version', lambda adcp_major_version: _c.json_type(adcp_major_version, 'integer')) and _c.descend(self, 'adcp_version', lambda adcp_version: _c.json_type(adcp_version, 'string')) and _c.descend(self, 'context_id', lambda context_id: _c.json_type(context_id, 'string')) and _c.descend(self, 'errors', lambda errors: _c.every(errors, lambda item: _c.only(item, ['code', 'message']) and (_c.descend(item, 'code', lambda code: code == 'NOT_FOUND') and _c.descend(item, 'message', lambda message: message == 'Reporting status resource is unavailable.')) and (_c.sent(item, 'code') and _c.sent(item, 'message')) and _c.json_type(item, 'object')) and _c.length(errors, maximum=1) and _c.length(errors, minimum=1)) and _c.descend(self, 'failure_kind', lambda failure_kind: failure_kind == 'lookup_unavailable' and _c.json_type(failure_kind, 'string')) and _c.descend(self, 'message', lambda message: message == 'Reporting status resource is unavailable.') and _c.descend(self, 'replayed', lambda replayed: _c.json_type(replayed, 'boolean')) and _c.descend(self, 'status', lambda status: status == 'failed' and _c.json_type(status, 'string')) and _c.descend(self, 'timestamp', lambda timestamp: _c.json_type(timestamp, 'string')) and _c.descend(self, 'view', lambda view: view in ['summary', 'periods', 'revision'])) and (_c.supplied(self, 'status') and _c.sent(self, 'view') and _c.supplied(self, 'failure_kind') and _c.sent(self, 'errors')), _c.only(self, ['adcp_error', 'adcp_major_version', 'adcp_version', 'context', 'context_id', 'errors', 'failure_kind', 'message', 'replayed', 'status', 'timestamp', 'view']) and (_c.descend(self, 'adcp_error', lambda adcp_error: (not (_c.supplied(adcp_error, 'buyer_reason')) or _c.supplied(adcp_error, 'recovery')) and (_c.descend(adcp_error, 'buyer_reason', lambda buyer_reason: (_c.descend(buyer_reason, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(buyer_reason, 'message', lambda message: _c.length(message, minimum=1) and _c.json_type(message, 'string'))) and (_c.supplied(buyer_reason, 'code') and _c.supplied(buyer_reason, 'message')) and _c.json_type(buyer_reason, 'object')) and _c.descend(adcp_error, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(adcp_error, 'details', lambda details: _c.json_type(details, 'object')) and _c.descend(adcp_error, 'field', lambda field: _c.json_type(field, 'string')) and _c.descend(adcp_error, 'issues', lambda issues: _c.every(issues, lambda item: (_c.descend(item, 'discriminator', lambda discriminator: _c.every(discriminator, lambda item: _c.only(item, ['property_name', 'value']) and (_c.descend(item, 'property_name', lambda property_name: _c.json_type(property_name, 'string')) and _c.descend(item, 'value', lambda value: _c.json_type(value, 'string', 'number', 'boolean', 'null'))) and (_c.supplied(item, 'property_name') and _c.supplied(item, 'value')) and _c.json_type(item, 'object')) and _c.json_type(discriminator, 'array')) and _c.descend(item, 'keyword', lambda keyword: _c.json_type(keyword, 'string')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'pointer', lambda pointer: _c.json_type(pointer, 'string')) and _c.descend(item, 'schemaPath', lambda schemaPath: _c.json_type(schemaPath, 'string')) and _c.descend(item, 'schema_id', lambda schema_id: _c.json_type(schema_id, 'string'))) and (_c.supplied(item, 'pointer') and _c.supplied(item, 'message') and _c.supplied(item, 'keyword')) and _c.json_type(item, 'object')) and _c.json_type(issues, 'array')) and _c.descend(adcp_error, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(adcp_error, 'recovery', lambda recovery: recovery in ['transient', 'correctable', 'terminal'] and _c.json_type(recovery, 'string')) and _c.descend(adcp_error, 'retry_after', lambda retry_after: _c.within(retry_after, le=3600) and _c.within(retry_after, ge=1) and _c.json_type(retry_after, 'number')) and _c.descend(adcp_error, 'sdk_id', lambda sdk_id: _c.json_type(sdk_id, 'string')) and _c.descend(adcp_error, 'source', lambda source: source in ['producer', 'sdk'] and _c.json_type(source, 'string')) and _c.descend(adcp_error, 'suggestion', lambda suggestion: _c.json_type(suggestion, 'string'))) and (_c.supplied(adcp_error, 'code') and _c.supplied(adcp_error, 'message')) and _c.json_type(adcp_error, 'object')) and _c.descend(self, 'adcp_major_version', lambda adcp_major_version: _c.json_type(adcp_major_version, 'integer')) and _c.descend(self, 'adcp_version', lambda adcp_version: _c.json_type(adcp_version, 'string')) and _c.descend(self, 'context_id', lambda context_id: _c.json_type(context_id, 'string')) and _c.descend(self, 'errors', lambda errors: _c.length(errors, minimum=1)) and _c.descend(self, 'failure_kind', lambda failure_kind: failure_kind == 'operational' and _c.json_type(failure_kind, 'string')) and _c.descend(self, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(self, 'replayed', lambda replayed: _c.json_type(replayed, 'boolean')) and _c.descend(self, 'status', lambda status: status == 'failed' and _c.json_type(status, 'string')) and _c.descend(self, 'timestamp', lambda timestamp: _c.json_type(timestamp, 'string')) and _c.descend(self, 'view', lambda view: view in ['summary', 'periods', 'revision'])) and (_c.supplied(self, 'status') and _c.sent(self, 'view') and _c.supplied(self, 'failure_kind') and _c.sent(self, 'errors'))].count(True) == 1 and _c.descend(self, 'status', lambda status: status == 'failed' and _c.json_type(status, 'string')) and _c.supplied(self, 'status'))].count(True) != 1: + raise PydanticCustomError( + 'oneOf', + 'GetReportingStatusResponse satisfies more than one of these mutually exclusive field groups: Successful lookup | Failed lookup', + ) + if not ( + (_c.descend(self, 'scope', lambda scope: (_c.descend(scope, 'coverage_complete', lambda coverage_complete: coverage_complete == True) and _c.descend(scope, 'scope_closed', lambda scope_closed: scope_closed == True)) and (_c.sent(scope, 'scope_closed') and _c.sent(scope, 'coverage_complete'))) if (_c.descend(self, 'health', lambda health: health == 'complete') and _c.sent(self, 'health')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetReportingStatusResponse violates a constraint its schema states outside its properties (media-buy/get-reporting-status-response.json, root conjunct 0) over coverage_complete, health, scope, scope_closed', + {'fields': 'coverage_complete, health, scope, scope_closed'}, + ) + if not ( + ((_c.descend(self, 'issues', lambda issues: _c.some(issues, lambda item: _c.descend(item, 'severity', lambda severity: severity == 'action_required') and _c.supplied(item, 'severity')) and _c.length(issues, minimum=1)) and _c.sent(self, 'issues')) if (_c.descend(self, 'health', lambda health: health == 'action_required') and _c.sent(self, 'health')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetReportingStatusResponse violates a constraint its schema states outside its properties (media-buy/get-reporting-status-response.json, root conjunct 1) over health, issues, severity', + {'fields': 'health, issues, severity'}, + ) + if not ( + ((_c.descend(self, 'issues', lambda issues: _c.every(issues, lambda item: _c.descend(item, 'severity', lambda severity: severity == 'delayed')) and _c.length(issues, minimum=1)) and _c.sent(self, 'issues')) if (_c.descend(self, 'health', lambda health: health == 'delayed') and _c.sent(self, 'health')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetReportingStatusResponse violates a constraint its schema states outside its properties (media-buy/get-reporting-status-response.json, root conjunct 2) over health, issues, severity', + {'fields': 'health, issues, severity'}, + ) + if not ( + ((_c.descend(self, 'issues', lambda issues: _c.length(issues, maximum=0)) and _c.sent(self, 'issues')) if (_c.descend(self, 'health', lambda health: health in ['healthy', 'waiting', 'complete']) and _c.sent(self, 'health')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetReportingStatusResponse violates a constraint its schema states outside its properties (media-buy/get-reporting-status-response.json, root conjunct 3) over health, issues', + {'fields': 'health, issues'}, + ) + if not ( + (((_c.descend(self, 'health', lambda health: health == 'action_required') and _c.descend(self, 'issues', lambda issues: _c.some(issues, lambda item: (_c.descend(item, 'code', lambda code: code == 'HISTORY_UNAVAILABLE') and _c.descend(item, 'severity', lambda severity: severity == 'action_required')) and (_c.supplied(item, 'code') and _c.supplied(item, 'severity'))) and _c.length(issues, minimum=1))) and _c.sent(self, 'issues')) if (_c.descend(self, 'scope', lambda scope: _c.descend(scope, 'coverage_complete', lambda coverage_complete: coverage_complete == False) and _c.sent(scope, 'coverage_complete')) and _c.sent(self, 'scope')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetReportingStatusResponse violates a constraint its schema states outside its properties (media-buy/get-reporting-status-response.json, root conjunct 4) over code, coverage_complete, health, issues, scope, severity', + {'fields': 'code, coverage_complete, health, issues, scope, severity'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/list_creative_formats_request.py b/src/adcp/types/domains/media_buy/list_creative_formats_request.py index 5b22fa3ab..d885ee6a6 100644 --- a/src/adcp/types/domains/media_buy/list_creative_formats_request.py +++ b/src/adcp/types/domains/media_buy/list_creative_formats_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/list_creative_formats_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -22,6 +22,12 @@ from ..enums import wcag_level as wcag_level_1 +from pydantic import WrapValidator + + +from adcp.types.coercion import coerce_to_unique_enum_list + + class ListCreativeFormatsRequest(AdcpRequest, AdcpVersionEnvelope): model_config = ConfigDict( extra='allow', @@ -94,15 +100,29 @@ class ListCreativeFormatsRequest(AdcpRequest, AdcpVersionEnvelope): description='Filter to formats that meet at least this WCAG conformance level (A < AA < AAA)' ), ] = None + # Order-preserving de-duplication of a repeated filter value. This is the one coercion that + # pydantic does not already do natively: an enum list accepts strings because the generated + # enums are StrEnum, and a model field accepts a dict because pydantic does that, but nothing + # drops a repeat. It was applied by a patch in _ergonomic.py; see salesagent-1jj5e. disclosure_positions: Annotated[ - list[disclosure_position.DisclosurePosition] | None, + Annotated[ + list[disclosure_position.DisclosurePosition] | None, + WrapValidator(coerce_to_unique_enum_list(disclosure_position.DisclosurePosition)), + ], Field( description="Filter to formats that support all of these disclosure positions. When a format has disclosure_capabilities, match against those positions. Otherwise fall back to supported_disclosure_positions. Use to find formats compatible with a brief's compliance requirements.", min_length=1, ), ] = None + # Order-preserving de-duplication of a repeated filter value. This is the one coercion that + # pydantic does not already do natively: an enum list accepts strings because the generated + # enums are StrEnum, and a model field accepts a dict because pydantic does that, but nothing + # drops a repeat. It was applied by a patch in _ergonomic.py; see salesagent-1jj5e. disclosure_persistence: Annotated[ - list[disclosure_persistence_1.DisclosurePersistence] | None, + Annotated[ + list[disclosure_persistence_1.DisclosurePersistence] | None, + WrapValidator(coerce_to_unique_enum_list(disclosure_persistence_1.DisclosurePersistence)), + ], Field( description='Filter to formats where each requested persistence mode is supported by at least one position in disclosure_capabilities. Different positions may satisfy different modes. Use to find formats compatible with jurisdiction-specific persistence requirements (e.g., continuous for EU AI Act).', min_length=1, diff --git a/src/adcp/types/domains/media_buy/list_products_request.py b/src/adcp/types/domains/media_buy/list_products_request.py index 6da7094a0..39c275264 100644 --- a/src/adcp/types/domains/media_buy/list_products_request.py +++ b/src/adcp/types/domains/media_buy/list_products_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/list_products_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import brand_key, canonical_account_ref from ..core import context as context_1 @@ -17,6 +17,12 @@ from . import product_discovery_criteria, product_fields +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ListProductsRequest(AdcpRequest, AdcpVersionEnvelope): model_config = ConfigDict( extra='forbid', @@ -68,3 +74,38 @@ class ListProductsRequest(AdcpRequest, AdcpVersionEnvelope): description='Opaque pricing version returned by a prior list_products response. Valid only with if_feed_version.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ListProductsRequest: + # Compiled from media-buy/list-products-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'if_pricing_version')) + or _c.supplied(self, 'if_feed_version') + ): + raise PydanticCustomError( + 'allOf', + 'ListProductsRequest violates a constraint its schema states outside its properties (media-buy/list-products-request.json, root conjunct 0) over if_feed_version, if_pricing_version', + {'fields': 'if_feed_version, if_pricing_version'}, + ) + if not ( + ((_c.sent(self, 'brand') or (_c.descend(self, 'account', lambda account: _c.sent(account, 'brand') and _c.sent(account, 'operator')) and _c.sent(self, 'account'))) if (_c.descend(self, 'criteria', lambda criteria: _c.sent(criteria, 'catalog')) and _c.sent(self, 'criteria')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ListProductsRequest violates a constraint its schema states outside its properties (media-buy/list-products-request.json, root conjunct 1) over account, brand, catalog, criteria, operator', + {'fields': 'account, brand, catalog, criteria, operator'}, + ) + if not ( + not (_c.descend(self, 'account', lambda account: _c.sent(account, 'brand') and _c.sent(account, 'operator')) and (_c.sent(self, 'account') and _c.sent(self, 'brand'))) + ): + raise PydanticCustomError( + 'allOf', + 'ListProductsRequest violates a constraint its schema states outside its properties (media-buy/list-products-request.json, root conjunct 2) over account, brand, operator', + {'fields': 'account, brand, operator'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/outcome_target.py b/src/adcp/types/domains/media_buy/outcome_target.py index e91193301..7585924d3 100644 --- a/src/adcp/types/domains/media_buy/outcome_target.py +++ b/src/adcp/types/domains/media_buy/outcome_target.py @@ -1,10 +1,10 @@ # generated by datamodel-codegen: # filename: media_buy/outcome_target.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated, Literal +from typing import Annotated, ClassVar, Literal from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, StrictFloat, model_validator @@ -14,6 +14,9 @@ from ..enums import forecastable_metric +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -69,19 +72,29 @@ class OutcomeTarget(AdCPBaseModel): ), ] = None cost_per: outcome_target_cost_per.OutcomeTargetCostPer | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'cost_per', 'volume'}) @model_validator(mode='after') def _require_schema_required_group(self) -> OutcomeTarget: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('volume',), ('cost_per',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'OutcomeTarget requires at least one of these field groups: volume | cost_per', - ) + # Compiled from media-buy/outcome-target.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # volume + ( + _c.sent(self, 'volume') + ) + # cost_per + or ( + _c.sent(self, 'cost_per') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'OutcomeTarget requires at least one of these field groups: volume | cost_per', + ) + return self diff --git a/src/adcp/types/domains/media_buy/package_control.py b/src/adcp/types/domains/media_buy/package_control.py index d70a2f583..61232bb80 100644 --- a/src/adcp/types/domains/media_buy/package_control.py +++ b/src/adcp/types/domains/media_buy/package_control.py @@ -1,22 +1,28 @@ # generated by datamodel-codegen: # filename: media_buy/package_control.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated, Literal +from typing import Annotated, ClassVar, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import bidding_policy, canonical_optimization_goal, keyword_target, targeting_input from ..enums import pacing as pacing_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + from pydantic_core import PydanticCustomError +from adcp.types._targeting_compat import TargetingOverlayMutation + + class CatalogId(ScalarStr): __slots__ = () _constraints = {'min_length': 1} @@ -64,8 +70,13 @@ class PackageControl(AdCPBaseModel): ), ] = None cancellation_reason: Annotated[str | None, Field(max_length=500, min_length=1)] = None + # The schema's mutation shape is TargetingOverlayInput and that is what the JSON schema + # advertises; the SDK additionally accepts an already-resolved TargetingOverlay instance, so a + # caller passing one back does not have to model_dump() it first. See + # adcp.types._targeting_compat. Annotated flattens, so the Field(...) below is still this + # field's own. targeting_overlay: Annotated[ - targeting_input.TargetingOverlayInput | None, + TargetingOverlayMutation, Field( description='Per-dimension targeting patch. Omission preserves the current dimension, a non-null value replaces it, and null clears it. The resulting effective targeting must remain within the accepted commercial envelope and is read back without null dimensions.' ), @@ -92,19 +103,113 @@ class PackageControl(AdCPBaseModel): optimization_goals: Annotated[ list[canonical_optimization_goal.CanonicalOptimizationGoal] | None, Field(min_length=1) ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'bidding', 'budget', 'canceled', 'catalog_ids', 'daily_budget_cap', 'impressions', 'keyword_targets_add', 'keyword_targets_remove', 'min_spend_target', 'negative_keywords_add', 'negative_keywords_remove', 'optimization_goals', 'pacing', 'paused', 'targeting_overlay'}) @model_validator(mode='after') def _require_schema_required_group(self) -> PackageControl: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('budget',), ('daily_budget_cap',), ('min_spend_target',), ('impressions',), ('pacing',), ('bidding',), ('paused',), ('canceled',), ('targeting_overlay',), ('catalog_ids',), ('keyword_targets_add',), ('keyword_targets_remove',), ('negative_keywords_add',), ('negative_keywords_remove',), ('optimization_goals',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'PackageControl requires at least one of these field groups: budget | daily_budget_cap | min_spend_target | impressions | pacing | bidding | paused | canceled | targeting_overlay | catalog_ids | keyword_targets_add | keyword_targets_remove | negative_keywords_add | negative_keywords_remove | optimization_goals', - ) + # Compiled from media-buy/package-control.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # budget + ( + _c.supplied(self, 'budget') + ) + # daily_budget_cap + or ( + _c.supplied(self, 'daily_budget_cap') + ) + # min_spend_target + or ( + _c.supplied(self, 'min_spend_target') + ) + # impressions + or ( + _c.sent(self, 'impressions') + ) + # pacing + or ( + _c.sent(self, 'pacing') + ) + # bidding + or ( + _c.supplied(self, 'bidding') + ) + # paused + or ( + _c.sent(self, 'paused') + ) + # canceled + or ( + _c.sent(self, 'canceled') + ) + # targeting_overlay + or ( + _c.sent(self, 'targeting_overlay') + ) + # catalog_ids + or ( + _c.sent(self, 'catalog_ids') + ) + # keyword_targets_add + or ( + _c.sent(self, 'keyword_targets_add') + ) + # keyword_targets_remove + or ( + _c.sent(self, 'keyword_targets_remove') + ) + # negative_keywords_add + or ( + _c.sent(self, 'negative_keywords_add') + ) + # negative_keywords_remove + or ( + _c.sent(self, 'negative_keywords_remove') + ) + # optimization_goals + or ( + _c.sent(self, 'optimization_goals') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'PackageControl requires at least one of these field groups: budget | daily_budget_cap | min_spend_target | impressions | pacing | bidding | paused | canceled | targeting_overlay | catalog_ids | keyword_targets_add | keyword_targets_remove | negative_keywords_add | negative_keywords_remove | optimization_goals', + ) + if not ( + not (False or False or False or False or False or False or False) + ): + raise PydanticCustomError( + 'allOf', + 'PackageControl violates a constraint its schema states outside its properties (media-buy/package-control.json, root conjunct 0) over catalogs, creative_assignments, creatives, end_time, pricing_option_id, product_id, start_time', + {'fields': 'catalogs, creative_assignments, creatives, end_time, pricing_option_id, product_id, start_time'}, + ) + if not ( + (not (_c.sent(self, 'keyword_targets_add') or _c.sent(self, 'keyword_targets_remove') or _c.sent(self, 'negative_keywords_add') or _c.sent(self, 'negative_keywords_remove')) if _c.sent(self, 'targeting_overlay') else True) + ): + raise PydanticCustomError( + 'allOf', + 'PackageControl violates a constraint its schema states outside its properties (media-buy/package-control.json, root conjunct 1) over keyword_targets_add, keyword_targets_remove, negative_keywords_add, negative_keywords_remove, targeting_overlay', + {'fields': 'keyword_targets_add, keyword_targets_remove, negative_keywords_add, negative_keywords_remove, targeting_overlay'}, + ) + if not ( + (_c.sent(self, 'canceled') if _c.sent(self, 'cancellation_reason') else True) + ): + raise PydanticCustomError( + 'allOf', + 'PackageControl violates a constraint its schema states outside its properties (media-buy/package-control.json, root conjunct 2) over canceled, cancellation_reason', + {'fields': 'canceled, cancellation_reason'}, + ) + if not ( + (not (_c.supplied(self, 'budget') or _c.supplied(self, 'daily_budget_cap') or _c.supplied(self, 'min_spend_target') or _c.sent(self, 'impressions') or _c.sent(self, 'pacing') or _c.supplied(self, 'bidding') or _c.sent(self, 'paused') or _c.sent(self, 'targeting_overlay') or _c.sent(self, 'catalog_ids') or _c.sent(self, 'keyword_targets_add') or _c.sent(self, 'keyword_targets_remove') or _c.sent(self, 'negative_keywords_add') or _c.sent(self, 'negative_keywords_remove') or _c.sent(self, 'optimization_goals')) if _c.sent(self, 'canceled') else True) + ): + raise PydanticCustomError( + 'allOf', + 'PackageControl violates a constraint its schema states outside its properties (media-buy/package-control.json, root conjunct 3) over bidding, budget, canceled, catalog_ids, daily_budget_cap, impressions, keyword_targets_add, keyword_targets_remove, min_spend_target, negative_keywords_add, negative_keywords_remove, optimization_goals, pacing, paused, targeting_overlay', + {'fields': 'bidding, budget, canceled, catalog_ids, daily_budget_cap, impressions, keyword_targets_add, keyword_targets_remove, min_spend_target, negative_keywords_add, negative_keywords_remove, optimization_goals, pacing, paused, targeting_overlay'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/package_request.py b/src/adcp/types/domains/media_buy/package_request.py index 2882032e5..b733bc3c4 100644 --- a/src/adcp/types/domains/media_buy/package_request.py +++ b/src/adcp/types/domains/media_buy/package_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/package_request.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -28,6 +28,15 @@ from ..enums import viewability_standard as viewability_standard_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + +from adcp.types._targeting_compat import TargetingOverlayMutation + + class Qualifier(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -219,8 +228,13 @@ class PackageRequest(AdcpVersionEnvelope): min_length=1, ), ] = None + # The schema's mutation shape is TargetingOverlayInput and that is what the JSON schema + # advertises; the SDK additionally accepts an already-resolved TargetingOverlay instance, so a + # caller passing one back does not have to model_dump() it first. See + # adcp.types._targeting_compat. Annotated flattens, so the Field(...) below is still this + # field's own. targeting_overlay: Annotated[ - targeting_input.TargetingOverlayInput | None, + TargetingOverlayMutation, Field( description="Optional package-specific targeting input with three states per dimension. Omission inherits targeting already bound to the configured product, a non-null value replaces that dimension, and null explicitly suppresses the configured/product default for that dimension. Null cannot remove inherent product scope: sellers reject a clear the product cannot execute rather than silently retaining the default. Non-null fields MUST be declared in the product's overlay_support unless they were already accepted during discovery. The one fixed-inventory restatement exception is placement_selection equal to the product's complete, explicitly enumerated mode: included placement set: that set is an inherent exact match across discovery, create, and update and does not require overlay_support.placement_selection; partial selection still requires a selectable product. Opaque property_list and collection_list references have no equivalent exception because their membership can change independently and cannot be proven equal from the product wire representation. Package readback echoes the complete effective targeting without null dimensions. A supported value with no current inventory returns PRODUCT_UNAVAILABLE rather than a silent substitute or reprice." ), @@ -287,12 +301,45 @@ class PackageRequest(AdcpVersionEnvelope): ), ] = None ext: ext_1.ExtensionObject | None = None - @model_validator(mode='after') - def _validate_format_params(self) -> PackageRequest: - if self.params is not None and self.format_kind is None: - raise ValueError('params requires format_kind') - if self.params is not None and self.format_kind == 'image': - if ('width' in self.params) != ('height' in self.params): - raise ValueError('image params width and height must co-occur') + def _require_schema_required_group(self) -> PackageRequest: + # Compiled from media-buy/package-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'params')) or _c.supplied(self, 'format_kind') + ): + raise PydanticCustomError( + 'allOf', + 'PackageRequest violates a constraint its schema states outside its properties (media-buy/package-request.json, root conjunct 0) over format_kind, params', + {'fields': 'format_kind, params'}, + ) + if not ( + not (_c.supplied(self, 'capability_ids') or (_c.sent(self, 'bidding') and _c.sent(self, 'bid_price')) or _c.supplied(self, 'budget_cap_timezone')) + ): + raise PydanticCustomError( + 'allOf', + 'PackageRequest violates a constraint its schema states outside its properties (media-buy/package-request.json, root conjunct 1) over bid_price, bidding, budget_cap_timezone, capability_ids', + {'fields': 'bid_price, bidding, budget_cap_timezone, capability_ids'}, + ) + if not ( + (_c.descend(self, 'optimization_goals', lambda optimization_goals: not (_c.some(optimization_goals, lambda item: _c.descend(item, 'target', lambda target: _c.descend(target, 'kind', lambda kind: kind in ['cost_per', 'per_ad_spend']) and _c.supplied(target, 'kind')) and _c.supplied(item, 'target')))) if _c.sent(self, 'bidding') else True) + ): + raise PydanticCustomError( + 'allOf', + 'PackageRequest violates a constraint its schema states outside its properties (media-buy/package-request.json, root conjunct 2) over bidding, kind, optimization_goals, target', + {'fields': 'bidding, kind, optimization_goals, target'}, + ) + if not ( + (_c.descend(self, 'params', lambda params: (not (_c.supplied(params, 'height')) or _c.supplied(params, 'width')) and (not (_c.supplied(params, 'width')) or _c.supplied(params, 'height'))) if (_c.descend(self, 'format_kind', lambda format_kind: format_kind == 'image') and (_c.sent(self, 'format_kind') and _c.sent(self, 'params'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'PackageRequest violates a constraint its schema states outside its properties (media-buy/package-request.json, root conjunct 3) over format_kind, height, params, width', + {'fields': 'format_kind, height, params, width'}, + ) return self diff --git a/src/adcp/types/domains/media_buy/package_update.py b/src/adcp/types/domains/media_buy/package_update.py index 4146ac47c..b38bd5d19 100644 --- a/src/adcp/types/domains/media_buy/package_update.py +++ b/src/adcp/types/domains/media_buy/package_update.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/package_update.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import bidding_policy, catalog from ..core import context as context_1 @@ -18,6 +18,15 @@ from ..enums import pacing as pacing_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + +from adcp.types._targeting_compat import TargetingOverlayMutation + + class KeywordTargetsAddItem(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -145,8 +154,13 @@ class PackageUpdate(AdCPBaseModel): min_length=1, ), ] = None + # The schema's mutation shape is TargetingOverlayInput and that is what the JSON schema + # advertises; the SDK additionally accepts an already-resolved TargetingOverlay instance, so a + # caller passing one back does not have to model_dump() it first. See + # adcp.types._targeting_compat. Annotated flattens, so the Field(...) below is still this + # field's own. targeting_overlay: Annotated[ - targeting_input.TargetingOverlayInput | None, + TargetingOverlayMutation, Field( description='Per-dimension targeting patch for this package. Omit targeting_overlay, or omit an individual dimension inside it, to leave the corresponding effective targeting unchanged. A non-null dimension replaces its current value; null clears it, including a value inherited from configured-product selection or a product default. Every resulting effective overlay must remain executable by the product. Sellers reject unsupported or partially applicable changes and use REQUOTE_REQUIRED when a change, including a broader inventory set, falls outside the priced envelope. placement_selection is purchased-inventory targeting; mode default restores the product default, null clears the dimension when the product permits it, and successful readback echoes the committed selected set when enumerable. If a patch removes a placement referenced by an existing creative assignment, the seller MUST reject the update unless the same atomic package mutation supplies a compatible complete creative_assignments replacement. Sellers MUST NOT silently delete assignments or retain orphan placement refs.' ), @@ -195,3 +209,29 @@ class PackageUpdate(AdCPBaseModel): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> PackageUpdate: + # Compiled from media-buy/package-update.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'product_id') or _c.supplied(self, 'format_ids') or _c.supplied(self, 'format_option_refs') or _c.supplied(self, 'format_kind') or _c.supplied(self, 'params') or _c.supplied(self, 'capability_ids') or _c.supplied(self, 'pricing_option_id') or _c.supplied(self, 'budget_cap_timezone') or (_c.descend(self, 'bidding', lambda bidding: _c.json_type(bidding, 'object')) and (_c.supplied(self, 'bidding') and _c.sent(self, 'bid_price')))) + ): + raise PydanticCustomError( + 'allOf', + 'PackageUpdate violates a constraint its schema states outside its properties (media-buy/package-update.json, root conjunct 0) over bid_price, bidding, budget_cap_timezone, capability_ids, format_ids, format_kind, format_option_refs, params, pricing_option_id, product_id', + {'fields': 'bid_price, bidding, budget_cap_timezone, capability_ids, format_ids, format_kind, format_option_refs, params, pricing_option_id, product_id'}, + ) + if not ( + (_c.descend(self, 'optimization_goals', lambda optimization_goals: not (_c.some(optimization_goals, lambda item: _c.descend(item, 'target', lambda target: _c.descend(target, 'kind', lambda kind: kind in ['cost_per', 'per_ad_spend']) and _c.supplied(target, 'kind')) and _c.supplied(item, 'target')))) if (_c.descend(self, 'bidding', lambda bidding: _c.json_type(bidding, 'object')) and _c.supplied(self, 'bidding')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'PackageUpdate violates a constraint its schema states outside its properties (media-buy/package-update.json, root conjunct 1) over bidding, kind, optimization_goals, target', + {'fields': 'bidding, kind, optimization_goals, target'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/product_discovery_criteria.py b/src/adcp/types/domains/media_buy/product_discovery_criteria.py index 565df258f..2f7fc56f2 100644 --- a/src/adcp/types/domains/media_buy/product_discovery_criteria.py +++ b/src/adcp/types/domains/media_buy/product_discovery_criteria.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/product_discovery_criteria.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import catalog_selection from ..core import media_buy_frequency_cap as media_buy_frequency_cap_1 @@ -22,6 +21,13 @@ from . import outcome_target as outcome_target_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class ProductId(ScalarStr): __slots__ = () _constraints = {'min_length': 1} @@ -87,3 +93,20 @@ class ProductDiscoveryCriteria(AdCPBaseModel): catalog: catalog_selection.CatalogSelection | None = None policy_ids: Annotated[list[PolicyId] | None, Field(min_length=1)] = None ext: dict[str, Any] | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProductDiscoveryCriteria: + # Compiled from media-buy/product-discovery-criteria.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + _c.members(self, minimum=1) + ): + raise PydanticCustomError( + 'allOf', + 'ProductDiscoveryCriteria violates a constraint its schema states outside its properties (media-buy/product-discovery-criteria.json, root conjunct 0)', + ) + return self diff --git a/src/adcp/types/domains/media_buy/product_purchase.py b/src/adcp/types/domains/media_buy/product_purchase.py index 571908505..0c34d6dc3 100644 --- a/src/adcp/types/domains/media_buy/product_purchase.py +++ b/src/adcp/types/domains/media_buy/product_purchase.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/product_purchase.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..core import ( audience_evidence_pin, @@ -24,6 +23,13 @@ from ..enums import pacing as pacing_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class CatalogId(ScalarStr): __slots__ = () _constraints = {'min_length': 1} @@ -148,3 +154,21 @@ class ProductPurchase(AdCPBaseModel): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProductPurchase: + # Compiled from media-buy/product-purchase.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (False or False or False) + ): + raise PydanticCustomError( + 'allOf', + 'ProductPurchase violates a constraint its schema states outside its properties (media-buy/product-purchase.json, root conjunct 0) over catalogs, creative_assignments, creatives', + {'fields': 'catalogs, creative_assignments, creatives'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/product_purchase_input.py b/src/adcp/types/domains/media_buy/product_purchase_input.py index 4c213947d..d45e9b77f 100644 --- a/src/adcp/types/domains/media_buy/product_purchase_input.py +++ b/src/adcp/types/domains/media_buy/product_purchase_input.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/product_purchase_input.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..core import ( audience_evidence_pin, @@ -24,6 +23,16 @@ from ..enums import pacing as pacing_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + +from adcp.types._targeting_compat import TargetingOverlayMutation + + class CatalogId(ScalarStr): __slots__ = () _constraints = {'min_length': 1} @@ -98,8 +107,13 @@ class ProductPurchaseInput(AdCPBaseModel): ), ] = None bidding: bidding_policy.BiddingPolicy | None = None + # The schema's mutation shape is TargetingOverlayInput and that is what the JSON schema + # advertises; the SDK additionally accepts an already-resolved TargetingOverlay instance, so a + # caller passing one back does not have to model_dump() it first. See + # adcp.types._targeting_compat. Annotated flattens, so the Field(...) below is still this + # field's own. targeting_overlay: Annotated[ - targeting_input.TargetingOverlayInput | None, + TargetingOverlayMutation, Field( description="Per-dimension targeting input. Omission inherits the selected product's configured/default dimension, a non-null value replaces it, and null explicitly suppresses it. A clear cannot remove inherent product scope." ), @@ -148,3 +162,21 @@ class ProductPurchaseInput(AdCPBaseModel): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProductPurchaseInput: + # Compiled from media-buy/product-purchase-input.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (False or False or False) + ): + raise PydanticCustomError( + 'allOf', + 'ProductPurchaseInput violates a constraint its schema states outside its properties (media-buy/product-purchase-input.json, root conjunct 0) over catalogs, creative_assignments, creatives', + {'fields': 'catalogs, creative_assignments, creatives'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/proposal_budget_constraint.py b/src/adcp/types/domains/media_buy/proposal_budget_constraint.py index fdbc10b80..df2e4be1b 100644 --- a/src/adcp/types/domains/media_buy/proposal_budget_constraint.py +++ b/src/adcp/types/domains/media_buy/proposal_budget_constraint.py @@ -1,15 +1,18 @@ # generated by datamodel-codegen: # filename: media_buy/proposal_budget_constraint.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations -from typing import Annotated +from typing import Annotated, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, StrictFloat, model_validator +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -20,19 +23,29 @@ class ProposalBudgetConstraint(AdCPBaseModel): min: Annotated[StrictFloat | None, Field(ge=0.0)] = None max: Annotated[StrictFloat | None, Field(ge=0.0)] = None currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'max', 'min'}) @model_validator(mode='after') def _require_schema_required_group(self) -> ProposalBudgetConstraint: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('min',), ('max',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'ProposalBudgetConstraint requires at least one of these field groups: min | max', - ) + # Compiled from media-buy/proposal-budget-constraint.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # min + ( + _c.sent(self, 'min') + ) + # max + or ( + _c.sent(self, 'max') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'ProposalBudgetConstraint requires at least one of these field groups: min | max', + ) + return self diff --git a/src/adcp/types/domains/media_buy/proposal_decline.py b/src/adcp/types/domains/media_buy/proposal_decline.py index 971962596..0a16c13f4 100644 --- a/src/adcp/types/domains/media_buy/proposal_decline.py +++ b/src/adcp/types/domains/media_buy/proposal_decline.py @@ -1,17 +1,23 @@ # generated by datamodel-codegen: # filename: media_buy/proposal_decline.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..enums import proposal_decline_reason +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class ProposalDecline(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -33,3 +39,21 @@ class ProposalDecline(AdCPBaseModel): min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ProposalDecline: + # Compiled from media-buy/proposal-decline.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'detail') if (_c.descend(self, 'reason', lambda reason: reason == 'other') and _c.sent(self, 'reason')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'ProposalDecline violates a constraint its schema states outside its properties (media-buy/proposal-decline.json, root conjunct 0) over detail, reason', + {'fields': 'detail, reason'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/request_proposals_request.py b/src/adcp/types/domains/media_buy/request_proposals_request.py index 10ad4b559..2320e1d8a 100644 --- a/src/adcp/types/domains/media_buy/request_proposals_request.py +++ b/src/adcp/types/domains/media_buy/request_proposals_request.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: media_buy/request_proposals_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest from adcp.types._str_enum import StrEnum -from typing import Annotated, Literal +from typing import Annotated, ClassVar, Literal, TypeAlias -from pydantic import ConfigDict, Field, model_validator +from pydantic import ConfigDict, Field, WrapValidator, model_validator from ..core import brand_key, canonical_account_ref from ..core import context as context_1 @@ -19,9 +19,15 @@ from . import product_discovery_criteria +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError +from adcp.types._branch_dispatch import BranchDispatchMixin + + class Status(StrEnum): open = 'open' closed = 'closed' @@ -36,7 +42,25 @@ class Opportunity(OpportunityContext): ] = 'open' -class RequestProposalsRequest(AdcpRequest, AdcpVersionEnvelope): +class RequestProposalsRequest(BranchDispatchMixin, AdcpRequest, AdcpVersionEnvelope): + """What every branch of media-buy/request-proposals-request.json shares. + + The root's own properties and requiredness. Each branch subclasses this + and states only what it changes, so the branches are one type with + different requiredness rather than unrelated classes: + + ``RequestProposalsRequestBrand`` + ``RequestProposalsRequestAccount`` + + This class carries the branches' CORE SCHEMA, so parsing it -- by + name, nested in another model, or through a TypeAdapter -- returns + the branch the document satisfies, and refuses a document that + satisfies none or two of them. + + RequestProposalsRequestBranches is the union over those branches, for a + caller that wants a type checker to force exhaustive narrowing. + """ + model_config = ConfigDict( extra='forbid', ) @@ -85,19 +109,134 @@ class RequestProposalsRequest(AdcpRequest, AdcpVersionEnvelope): description='Optional planning-cycle context that the seller associates with every proposal created by this request.' ), ] = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'account', 'brand', 'operator'}) + + _ROOT_REQUIRED: ClassVar[frozenset[str]] = frozenset({'brief', 'idempotency_key'}) + + @classmethod + def _branch_models(cls) -> tuple[type, ...]: + # The branches subclass this class, so they are not defined while + # this body is compiled. The hook resolves them on first use, after + # the model_rebuild below. + return ( + RequestProposalsRequestBrand, + RequestProposalsRequestAccount, + ) + + @classmethod + def _branch_combinator(cls) -> str: + # The keyword a refusal names, from the root's own schema. The + # union the hook builds carries the wrapper that supplies it. + return 'anyOf' @model_validator(mode='after') def _require_schema_required_group(self) -> RequestProposalsRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('brand',), ('account',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'RequestProposalsRequest requires at least one of these field groups: brand | account', - ) + # Compiled from media-buy/request-proposals-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # brand + ( + _c.sent(self, 'brand') + ) + # account + or ( + _c.descend(self, 'account', lambda account: _c.sent(account, 'brand') and _c.sent(account, 'operator')) + and _c.sent(self, 'account') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'RequestProposalsRequest requires at least one of these field groups: brand | account', + ) + if not ( + not (_c.descend(self, 'account', lambda account: _c.sent(account, 'brand') and _c.sent(account, 'operator')) and (_c.sent(self, 'account') and _c.sent(self, 'brand'))) + ): + raise PydanticCustomError( + 'allOf', + 'RequestProposalsRequest violates a constraint its schema states outside its properties (media-buy/request-proposals-request.json, root conjunct 0) over account, brand, operator', + {'fields': 'account, brand, operator'}, + ) + return self + +class RequestProposalsRequestBrand(RequestProposalsRequest): + """The anyOf/0 branch. + + One branch of media-buy/request-proposals-request.json, stated as a shape: anyOf/0 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + brand: Annotated[brand_key.BrandKey, Field(description='Alternative brand source for proposal terms. Provide either top-level brand or a natural-key account containing brand and operator, not both.')] + @model_validator(mode='after') + def _require_schema_required_group(self) -> RequestProposalsRequestBrand: + # Compiled from media-buy/request-proposals-request.json anyOf/0: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + not (_c.descend(self, 'account', lambda account: _c.sent(account, 'brand') and _c.sent(account, 'operator')) and (_c.sent(self, 'account') and _c.sent(self, 'brand'))) + ): + raise PydanticCustomError( + 'anyOf', + 'RequestProposalsRequestBrand violates what media-buy/request-proposals-request.json states for its anyOf/0 branch beyond the fields it requires and forbids', + ) + return self + + +class RequestProposalsRequestAccount(RequestProposalsRequest): + """The anyOf/1 branch. + + One branch of media-buy/request-proposals-request.json, stated as a shape: anyOf/1 requires + what it declares below without a default, and types what it forbids + as ``None``. + """ + + account: Annotated[canonical_account_ref.CanonicalAccountReference, Field(description='Alternative brand source for proposal terms. Provide either this natural-key account containing brand and operator or top-level brand, not both.')] + @model_validator(mode='after') + def _require_schema_required_group(self) -> RequestProposalsRequestAccount: + # Compiled from media-buy/request-proposals-request.json anyOf/1: what this branch states + # that its field set cannot carry -- a value one level down, an + # overlay on an array's items, a bound. Requiredness and absence + # are the class's own shape and are not re-checked here. + if not ( + _c.descend(self, 'account', lambda account: _c.sent(account, 'brand') and _c.sent(account, 'operator')) and not (_c.descend(self, 'account', lambda account: _c.sent(account, 'brand') and _c.sent(account, 'operator')) and (_c.sent(self, 'account') and _c.sent(self, 'brand'))) + ): + raise PydanticCustomError( + 'anyOf', + 'RequestProposalsRequestAccount violates what media-buy/request-proposals-request.json states for its anyOf/1 branch beyond the fields it requires and forbids', + ) + return self + + +#: media-buy/request-proposals-request.json's branches, proven pairwise disjoint, so a parsed +#: document comes back as exactly one of them. Annotate this to make a +#: type checker force exhaustive narrowing; annotate RequestProposalsRequest to +#: say "a request of this kind" and get the same strictness at runtime. +#: +#: The wrapper names the root combinator on a refusal. A document that +#: satisfies no branch fails each branch at field validation, so pydantic +#: reports 'missing' and nothing names the keyword core/error.json +#: requires in issues[].keyword. +RequestProposalsRequestBranches: TypeAlias = Annotated[ + RequestProposalsRequestBrand | RequestProposalsRequestAccount, + WrapValidator( + _c.name_the_combinator('anyOf', RequestProposalsRequest._branch_models(), RequestProposalsRequest._ROOT_REQUIRED) + ), +] + +# RequestProposalsRequest is declared above its branches, so _branch_models +# cannot resolve them while the class body is compiled. The rebuild is +# what installs the branches' schema -- a class's core schema is built +# at class creation, so the hook's earlier return is already cached. +# +# This is not a surviving patch. What this series deletes is a model +# patched from a FOREIGN module: a field installed that the class never +# declared, with a forced rebuild to make the patch take. A rebuild in +# the declaring module, resolving a forward reference the class cannot +# name at declaration time, is what model_rebuild is for. +RequestProposalsRequest.model_rebuild(force=True) diff --git a/src/adcp/types/domains/media_buy/sync_audiences_request.py b/src/adcp/types/domains/media_buy/sync_audiences_request.py index dacc27e58..fca6fb319 100644 --- a/src/adcp/types/domains/media_buy/sync_audiences_request.py +++ b/src/adcp/types/domains/media_buy/sync_audiences_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/sync_audiences_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,8 +10,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import account_ref, audience_member, audience_source from ..core import context as context_1 @@ -20,6 +19,13 @@ from ..enums import consent_basis as consent_basis_1 +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class AudienceType(StrEnum): crm = 'crm' suppression = 'suppression' @@ -125,3 +131,21 @@ class SyncAudiencesRequest(AdcpRequest, AdcpVersionEnvelope): ] = False context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> SyncAudiencesRequest: + # Compiled from media-buy/sync-audiences-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'audiences') if (_c.descend(self, 'delete_missing', lambda delete_missing: delete_missing == True) and _c.sent(self, 'delete_missing')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SyncAudiencesRequest violates a constraint its schema states outside its properties (media-buy/sync-audiences-request.json, root conjunct 0) over audiences, delete_missing', + {'fields': 'audiences, delete_missing'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/sync_catalogs_request.py b/src/adcp/types/domains/media_buy/sync_catalogs_request.py index df99e8e9f..ef39e6e16 100644 --- a/src/adcp/types/domains/media_buy/sync_catalogs_request.py +++ b/src/adcp/types/domains/media_buy/sync_catalogs_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/sync_catalogs_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import ( account_ref, @@ -23,6 +23,12 @@ from ..enums import validation_mode as validation_mode_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class SyncCatalogsRequest(AdcpRequest, AdcpVersionEnvelope): model_config = ConfigDict( extra='allow', @@ -98,3 +104,29 @@ class SyncCatalogsRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> SyncCatalogsRequest: + # Compiled from media-buy/sync-catalogs-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'catalogs') if (_c.descend(self, 'delete_missing', lambda delete_missing: delete_missing == True) and _c.sent(self, 'delete_missing')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SyncCatalogsRequest violates a constraint its schema states outside its properties (media-buy/sync-catalogs-request.json, root conjunct 0) over catalogs, delete_missing', + {'fields': 'catalogs, delete_missing'}, + ) + if not ( + not (_c.descend(self, 'dry_run', lambda dry_run: dry_run == True) and (_c.sent(self, 'dry_run') and _c.sent(self, 'item_availability_updates'))) + ): + raise PydanticCustomError( + 'allOf', + 'SyncCatalogsRequest violates a constraint its schema states outside its properties (media-buy/sync-catalogs-request.json, root conjunct 1) over dry_run, item_availability_updates', + {'fields': 'dry_run, item_availability_updates'}, + ) + return self diff --git a/src/adcp/types/domains/media_buy/sync_reporting_receipts_request.py b/src/adcp/types/domains/media_buy/sync_reporting_receipts_request.py index a04986daf..7b91461e6 100644 --- a/src/adcp/types/domains/media_buy/sync_reporting_receipts_request.py +++ b/src/adcp/types/domains/media_buy/sync_reporting_receipts_request.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/sync_reporting_receipts_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest, SchemaInt -from typing import Annotated +from typing import Annotated, ClassVar from pydantic import ConfigDict, Field, model_validator @@ -17,6 +17,9 @@ from ..core.version_envelope import AdcpVersionEnvelope +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -64,19 +67,29 @@ class SyncReportingReceiptsRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'adjustment_receipts', 'receipts'}) @model_validator(mode='after') def _require_schema_required_group(self) -> SyncReportingReceiptsRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('receipts',), ('adjustment_receipts',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'SyncReportingReceiptsRequest requires at least one of these field groups: receipts | adjustment_receipts', - ) + # Compiled from media-buy/sync-reporting-receipts-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # receipts + ( + _c.sent(self, 'receipts') + ) + # adjustment_receipts + or ( + _c.sent(self, 'adjustment_receipts') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'SyncReportingReceiptsRequest requires at least one of these field groups: receipts | adjustment_receipts', + ) + return self diff --git a/src/adcp/types/domains/media_buy/update_media_buy_request.py b/src/adcp/types/domains/media_buy/update_media_buy_request.py index 0cf88c012..f597d6c99 100644 --- a/src/adcp/types/domains/media_buy/update_media_buy_request.py +++ b/src/adcp/types/domains/media_buy/update_media_buy_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/update_media_buy_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import account_ref, bidding_policy from ..core import budget_allocation as budget_allocation_1 @@ -26,6 +26,12 @@ from . import package_request, package_update +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class TotalBudget(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -175,3 +181,29 @@ class UpdateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): ] context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> UpdateMediaBuyRequest: + # Compiled from media-buy/update-media-buy-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((_c.descend(self, 'new_packages', lambda new_packages: _c.every(new_packages, lambda item: not (_c.sent(item, 'bid_price') or (_c.descend(item, 'optimization_goals', lambda optimization_goals: _c.some(optimization_goals, lambda item: _c.descend(item, 'target', lambda target: _c.descend(target, 'kind', lambda kind: kind in ['cost_per', 'per_ad_spend']) and _c.supplied(target, 'kind')) and _c.supplied(item, 'target'))) and _c.sent(item, 'optimization_goals'))))) and _c.descend(self, 'packages', lambda packages: _c.every(packages, lambda item: not (_c.sent(item, 'bid_price') or (_c.descend(item, 'optimization_goals', lambda optimization_goals: _c.some(optimization_goals, lambda item: _c.descend(item, 'target', lambda target: _c.descend(target, 'kind', lambda kind: kind in ['cost_per', 'per_ad_spend']) and _c.supplied(target, 'kind')) and _c.supplied(item, 'target'))) and _c.sent(item, 'optimization_goals')))))) if (_c.descend(self, 'bidding', lambda bidding: _c.json_type(bidding, 'object')) and _c.supplied(self, 'bidding')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'UpdateMediaBuyRequest violates a constraint its schema states outside its properties (media-buy/update-media-buy-request.json, root conjunct 0) over bid_price, bidding, kind, new_packages, optimization_goals, packages, target', + {'fields': 'bid_price, bidding, kind, new_packages, optimization_goals, packages, target'}, + ) + if not ( + (_c.descend(self, 'new_packages', lambda new_packages: _c.every(new_packages, lambda item: not (_c.sent(item, 'min_spend_target')) and _c.sent(item, 'budget'))) if (not (_c.descend(self, 'budget_allocation', lambda budget_allocation: _c.descend(budget_allocation, 'mode', lambda mode: mode == 'seller_optimized') and _c.sent(budget_allocation, 'mode')) and _c.sent(self, 'budget_allocation')) and _c.sent(self, 'new_packages')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'UpdateMediaBuyRequest violates a constraint its schema states outside its properties (media-buy/update-media-buy-request.json, root conjunct 1) over budget, budget_allocation, min_spend_target, mode, new_packages', + {'fields': 'budget, budget_allocation, min_spend_target, mode, new_packages'}, + ) + return self diff --git a/src/adcp/types/domains/pricing_options/__init__.py b/src/adcp/types/domains/pricing_options/__init__.py index 69b875cae..25ba89889 100644 --- a/src/adcp/types/domains/pricing_options/__init__.py +++ b/src/adcp/types/domains/pricing_options/__init__.py @@ -9,29 +9,94 @@ it from its own schema's module, ``adcp.types.domains.pricing_options.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.pricing_options.cpa_option import CpaPricingOption -from adcp.types.domains.pricing_options.cpc_option import CpcPricingOption -from adcp.types.domains.pricing_options.cpcv_option import CpcvPricingOption -from adcp.types.domains.pricing_options.cpm_option import CpmPricingOption -from adcp.types.domains.pricing_options.cpp_option import CppPricingOption -from adcp.types.domains.pricing_options.cpv_option import ( - CpvPricingOption, - ViewThreshold, - ViewThreshold1, -) -from adcp.types.domains.pricing_options.flat_rate_option import FlatRatePricingOption -from adcp.types.domains.pricing_options.price_breakdown import Adjustment, PriceBreakdown -from adcp.types.domains.pricing_options.price_guidance import PriceGuidance -from adcp.types.domains.pricing_options.revenue_share_option import RevenueSharePricingOption -from adcp.types.domains.pricing_options.time_option import TimeBasedPricingOption, TimeUnit -from adcp.types.domains.pricing_options.vcpm_option import VcpmPricingOption +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.pricing_options.cpa_option import CpaPricingOption + from adcp.types.domains.pricing_options.cpc_option import CpcPricingOption + from adcp.types.domains.pricing_options.cpcv_option import CpcvPricingOption + from adcp.types.domains.pricing_options.cpm_option import CpmPricingOption + from adcp.types.domains.pricing_options.cpp_option import CppPricingOption + from adcp.types.domains.pricing_options.cpv_option import ( + CpvPricingOption, + ViewThreshold, + ViewThreshold1, + ) + from adcp.types.domains.pricing_options.flat_rate_option import FlatRatePricingOption + from adcp.types.domains.pricing_options.price_breakdown import Adjustment, PriceBreakdown + from adcp.types.domains.pricing_options.price_guidance import PriceGuidance + from adcp.types.domains.pricing_options.revenue_share_option import RevenueSharePricingOption + from adcp.types.domains.pricing_options.time_option import TimeBasedPricingOption, TimeUnit + from adcp.types.domains.pricing_options.vcpm_option import VcpmPricingOption + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "pricing_options.cpa_option": ("CpaPricingOption",), + "pricing_options.cpc_option": ("CpcPricingOption",), + "pricing_options.cpcv_option": ("CpcvPricingOption",), + "pricing_options.cpm_option": ("CpmPricingOption",), + "pricing_options.cpp_option": ("CppPricingOption",), + "pricing_options.cpv_option": ( + "CpvPricingOption", + "ViewThreshold", + "ViewThreshold1", + ), + "pricing_options.flat_rate_option": ("FlatRatePricingOption",), + "pricing_options.price_breakdown": ( + "Adjustment", + "PriceBreakdown", + ), + "pricing_options.price_guidance": ("PriceGuidance",), + "pricing_options.revenue_share_option": ("RevenueSharePricingOption",), + "pricing_options.time_option": ( + "TimeBasedPricingOption", + "TimeUnit", + ), + "pricing_options.vcpm_option": ("VcpmPricingOption",), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/pricing_options/revenue_share_option.py b/src/adcp/types/domains/pricing_options/revenue_share_option.py index c9efe72d4..6cde7a8f3 100644 --- a/src/adcp/types/domains/pricing_options/revenue_share_option.py +++ b/src/adcp/types/domains/pricing_options/revenue_share_option.py @@ -1,17 +1,23 @@ # generated by datamodel-codegen: # filename: pricing_options/revenue_share_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..enums import event_type as event_type_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class RevenueSharePricingOption(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -70,3 +76,29 @@ class RevenueSharePricingOption(AdCPBaseModel): min_length=1, ), ] + @model_validator(mode='after') + def _require_schema_required_group(self) -> RevenueSharePricingOption: + # Compiled from pricing-options/revenue-share-option.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.sent(self, 'custom_event_name') if (_c.descend(self, 'event_type', lambda event_type: event_type == 'custom') and _c.sent(self, 'event_type')) else not (_c.sent(self, 'custom_event_name'))) + ): + raise PydanticCustomError( + 'allOf', + 'RevenueSharePricingOption violates a constraint its schema states outside its properties (pricing-options/revenue-share-option.json, root conjunct 0) over custom_event_name, event_type', + {'fields': 'custom_event_name, event_type'}, + ) + if not ( + not (_c.supplied(self, 'fixed_price') or _c.supplied(self, 'floor_price') or _c.supplied(self, 'price_guidance') or _c.supplied(self, 'max_bid')) + ): + raise PydanticCustomError( + 'allOf', + 'RevenueSharePricingOption violates a constraint its schema states outside its properties (pricing-options/revenue-share-option.json, root conjunct 1) over fixed_price, floor_price, max_bid, price_guidance', + {'fields': 'fixed_price, floor_price, max_bid, price_guidance'}, + ) + return self diff --git a/src/adcp/types/domains/property/__init__.py b/src/adcp/types/domains/property/__init__.py index e46344424..18168f9a6 100644 --- a/src/adcp/types/domains/property/__init__.py +++ b/src/adcp/types/domains/property/__init__.py @@ -9,60 +9,170 @@ it from its own schema's module, ``adcp.types.domains.property.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.property.authorization_result import AuthorizationResult, Violation -from adcp.types.domains.property.base_property_source import ( - BasePropertySource, - BasePropertySource1, - BasePropertySource2, - BasePropertySource3, -) -from adcp.types.domains.property.create_property_list_request import CreatePropertyListRequest -from adcp.types.domains.property.create_property_list_response import CreatePropertyListResponse -from adcp.types.domains.property.delete_property_list_request import DeletePropertyListRequest -from adcp.types.domains.property.delete_property_list_response import DeletePropertyListResponse -from adcp.types.domains.property.delivery_record import DeliveryRecord -from adcp.types.domains.property.get_property_list_request import GetPropertyListRequest, Pagination -from adcp.types.domains.property.get_property_list_response import GetPropertyListResponse -from adcp.types.domains.property.list_property_lists_request import ListPropertyListsRequest -from adcp.types.domains.property.list_property_lists_response import ListPropertyListsResponse -from adcp.types.domains.property.property_error import Code, PropertyError -from adcp.types.domains.property.property_feature import PropertyFeature -from adcp.types.domains.property.property_feature_definition import ( - Coverage, - PropertyFeatureDefinition, - Range, - Type, -) -from adcp.types.domains.property.property_feature_result import ( - CoverageStatus, - PropertyFeatureResult, -) -from adcp.types.domains.property.property_feature_value import PropertyFeatureValue -from adcp.types.domains.property.property_list import PropertyList -from adcp.types.domains.property.property_list_changed_webhook import ( - ChangeSummary, - PropertyListChangedWebhook, -) -from adcp.types.domains.property.property_list_filters import CountriesAllItem, PropertyListFilters -from adcp.types.domains.property.update_property_list_request import UpdatePropertyListRequest -from adcp.types.domains.property.update_property_list_response import UpdatePropertyListResponse -from adcp.types.domains.property.validate_property_delivery_request import ( - ValidatePropertyDeliveryRequest, -) -from adcp.types.domains.property.validate_property_delivery_response import ( - Aggregate, - AuthorizationSummary, - Summary, - ValidatePropertyDeliveryResponse, -) -from adcp.types.domains.property.validation_result import Feature, Requirement, ValidationResult +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.property.authorization_result import AuthorizationResult, Violation + from adcp.types.domains.property.base_property_source import ( + BasePropertySource, + BasePropertySource1, + BasePropertySource2, + BasePropertySource3, + ) + from adcp.types.domains.property.create_property_list_request import CreatePropertyListRequest + from adcp.types.domains.property.create_property_list_response import CreatePropertyListResponse + from adcp.types.domains.property.delete_property_list_request import DeletePropertyListRequest + from adcp.types.domains.property.delete_property_list_response import DeletePropertyListResponse + from adcp.types.domains.property.delivery_record import DeliveryRecord + from adcp.types.domains.property.get_property_list_request import ( + GetPropertyListRequest, + Pagination, + ) + from adcp.types.domains.property.get_property_list_response import GetPropertyListResponse + from adcp.types.domains.property.list_property_lists_request import ListPropertyListsRequest + from adcp.types.domains.property.list_property_lists_response import ListPropertyListsResponse + from adcp.types.domains.property.property_error import Code, PropertyError + from adcp.types.domains.property.property_feature import PropertyFeature + from adcp.types.domains.property.property_feature_definition import ( + Coverage, + PropertyFeatureDefinition, + Range, + Type, + ) + from adcp.types.domains.property.property_feature_result import ( + CoverageStatus, + PropertyFeatureResult, + ) + from adcp.types.domains.property.property_feature_value import PropertyFeatureValue + from adcp.types.domains.property.property_list import PropertyList + from adcp.types.domains.property.property_list_changed_webhook import ( + ChangeSummary, + PropertyListChangedWebhook, + ) + from adcp.types.domains.property.property_list_filters import ( + CountriesAllItem, + PropertyListFilters, + ) + from adcp.types.domains.property.update_property_list_request import UpdatePropertyListRequest + from adcp.types.domains.property.update_property_list_response import UpdatePropertyListResponse + from adcp.types.domains.property.validate_property_delivery_request import ( + ValidatePropertyDeliveryRequest, + ) + from adcp.types.domains.property.validate_property_delivery_response import ( + Aggregate, + AuthorizationSummary, + Summary, + ValidatePropertyDeliveryResponse, + ) + from adcp.types.domains.property.validation_result import Feature, Requirement, ValidationResult + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "property.authorization_result": ( + "AuthorizationResult", + "Violation", + ), + "property.base_property_source": ( + "BasePropertySource", + "BasePropertySource1", + "BasePropertySource2", + "BasePropertySource3", + ), + "property.create_property_list_request": ("CreatePropertyListRequest",), + "property.create_property_list_response": ("CreatePropertyListResponse",), + "property.delete_property_list_request": ("DeletePropertyListRequest",), + "property.delete_property_list_response": ("DeletePropertyListResponse",), + "property.delivery_record": ("DeliveryRecord",), + "property.get_property_list_request": ( + "GetPropertyListRequest", + "Pagination", + ), + "property.get_property_list_response": ("GetPropertyListResponse",), + "property.list_property_lists_request": ("ListPropertyListsRequest",), + "property.list_property_lists_response": ("ListPropertyListsResponse",), + "property.property_error": ( + "Code", + "PropertyError", + ), + "property.property_feature": ("PropertyFeature",), + "property.property_feature_definition": ( + "Coverage", + "PropertyFeatureDefinition", + "Range", + "Type", + ), + "property.property_feature_result": ( + "CoverageStatus", + "PropertyFeatureResult", + ), + "property.property_feature_value": ("PropertyFeatureValue",), + "property.property_list": ("PropertyList",), + "property.property_list_changed_webhook": ( + "ChangeSummary", + "PropertyListChangedWebhook", + ), + "property.property_list_filters": ( + "CountriesAllItem", + "PropertyListFilters", + ), + "property.update_property_list_request": ("UpdatePropertyListRequest",), + "property.update_property_list_response": ("UpdatePropertyListResponse",), + "property.validate_property_delivery_request": ("ValidatePropertyDeliveryRequest",), + "property.validate_property_delivery_response": ( + "Aggregate", + "AuthorizationSummary", + "Summary", + "ValidatePropertyDeliveryResponse", + ), + "property.validation_result": ( + "Feature", + "Requirement", + "ValidationResult", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/protocol/__init__.py b/src/adcp/types/domains/protocol/__init__.py index 4a8bf2913..9ac38a0d6 100644 --- a/src/adcp/types/domains/protocol/__init__.py +++ b/src/adcp/types/domains/protocol/__init__.py @@ -9,199 +9,440 @@ it from its own schema's module, ``adcp.types.domains.protocol.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.protocol.get_adcp_capabilities_request import ( - GetAdcpCapabilitiesRequest, - Protocol, -) -from adcp.types.domains.protocol.get_adcp_capabilities_response import ( - AcceptancePolicyDiscovery, - AccessMode, - Account, - Accreditation, - Adcp, - AgeRestriction, - Algorithm, - AttributionWindow, - AudienceEvidence, - AudienceTargeting, - Brand, - BudgetCapping, - BudgetCappingTimezoneBasis, - BuyingMode, - CapabilityChanges, - CapabilityReportingDeliveryMethod, - ChangeFeed, - ChangeFeed1, - ComplianceTesting, - CompromiseNotification, - ContentStandards, - ConversionTracking, - CoversContentDigest, - Creative, - CreativeApprovalMode, - CreativeFeature, - CreativeSpecs, - DataProviderDomain, - DefaultProfileId, - Demographics, - DiscoveryMode, - Endpoint, - EventType, - EventType3, - Execution, - ExtensionsSupportedItem, - Features, - Format, - FrequencyCapping, - GeoMetros, - GeoProximity, - GetAdcpCapabilitiesResponse, - Governance, - GovernanceEnforcement, - Idempotency, - Idempotency1, - Identity, - IdentityUpdates, - IdentityUpdates1, - KeyOrigins, - KeywordTargets, - Language, - LifecycleTool, - Localization, - MajorVersion, - MatchingLatencyHours, - Measurement, - MeasurementGateway, - MediaBuy, - Metric, - Mode, - MraidVersion, - Multiplicity, - NegativeKeywords, - Notifications, - Notifications1, - Notifications2, - Notifications3, - Oauth, - Operation, - Pattern, - PerformanceFeedback, - Portfolio, - Preview, - PrimaryCountry, - Principal, - PropagationSurface, - PropertyFeature, - ProposalRefinement, - ProtocolMethodsRequiredForItem, - ProtocolMethodsSupportedForItem, - ProtocolMethodsWarnForItem, - PublisherDomain, - Range, - RegistrationTask, - RelationshipNotifications, - RenderingOrigin, - ReportingDestinationOffering, - RepresentationResolution, - RequestSigning, - RequiredForItem, - Requirement, - ResourceType, - RightsAttestations, - Route, - RuntimeAttestations, - SignalActivation, - Signals, - SponsoredIntelligence, - SupportedChange, - SupportedDimension, - SupportedForItem, - SupportedFormat, - SupportedIdentifierType, - SupportedOptimizationMetric, - SupportedPresenceMode, - SupportedProtocol, - SupportedRequirementMode, - SupportedScope, - SupportedSection, - SupportedTarget, - SupportedTarget1, - SupportedVersion, - Targeting, - Tasks, - Tasks1, - Tasks2, - Tasks3, - Tasks4, - Tasks5, - Tasks6, - Tasks7, - Tasks8, - Tasks9, - Transport, - Transport1, - TrustedMatch, - Type6, - VariantDimension, - VastValidation, - VendorMetricOptimization, - WarnForItem, - WebhookSigning, - WholesaleFeedVersioning, - WholesaleFeedWebhooks, -) -from adcp.types.domains.protocol.get_principal_request import GetPrincipalRequest -from adcp.types.domains.protocol.get_principal_response import ( - GetPrincipalResponse, - PrincipalCurrentResult, - PrincipalReadFailedResult, - PrincipalRecognizedResult, - PrincipalUnconfiguredResult, - Result6, - Result7, - Result9, -) -from adcp.types.domains.protocol.get_task_status_request import GetTaskStatusRequest -from adcp.types.domains.protocol.get_task_status_response import ( - Details, - Error, - GetTaskStatusResponse, - HistoryItem, - Progress, -) -from adcp.types.domains.protocol.list_tasks_request import Field1, Filters, ListTasksRequest, Sort -from adcp.types.domains.protocol.list_tasks_response import ( - Direction, - Domain, - DomainBreakdown, - ListTasksResponse, - QuerySummary, - SortApplied, - Task, -) -from adcp.types.domains.protocol.sync_agent_notification_configs_request import ( - SyncAgentNotificationConfigsRequest, -) -from adcp.types.domains.protocol.sync_agent_notification_configs_response import ( - SyncAgentNotificationConfigsResponse, -) -from adcp.types.domains.protocol.sync_principal_request import Configuration, SyncPrincipalRequest -from adcp.types.domains.protocol.sync_principal_response import ( - Action33, - PrincipalAppliedResult, - PrincipalSyncFailedResult, - PrincipalValidatedResult, - Result17, - Result19, - SyncPrincipalResponse, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.protocol.get_adcp_capabilities_request import ( + GetAdcpCapabilitiesRequest, + Protocol, + ) + from adcp.types.domains.protocol.get_adcp_capabilities_response import ( + AcceptancePolicyDiscovery, + AccessMode, + Account, + Accreditation, + Adcp, + AgeRestriction, + Algorithm, + AttributionWindow, + AudienceEvidence, + AudienceTargeting, + Brand, + BudgetCapping, + BudgetCappingTimezoneBasis, + BuyingMode, + CapabilityChanges, + CapabilityReportingDeliveryMethod, + ChangeFeed, + ChangeFeed1, + ComplianceTesting, + CompromiseNotification, + ContentStandards, + ConversionTracking, + CoversContentDigest, + Creative, + CreativeApprovalMode, + CreativeFeature, + CreativeSpecs, + DataProviderDomain, + DefaultProfileId, + Demographics, + DiscoveryMode, + Endpoint, + EventType, + EventType3, + Execution, + ExtensionsSupportedItem, + Features, + Format, + FrequencyCapping, + GeoMetros, + GeoProximity, + GetAdcpCapabilitiesResponse, + Governance, + GovernanceEnforcement, + Idempotency, + Idempotency1, + Identity, + IdentityUpdates, + IdentityUpdates1, + KeyOrigins, + KeywordTargets, + Language, + LifecycleTool, + Localization, + MajorVersion, + MatchingLatencyHours, + Measurement, + MeasurementGateway, + MediaBuy, + Metric, + Mode, + MraidVersion, + Multiplicity, + NegativeKeywords, + Notifications, + Notifications1, + Notifications2, + Notifications3, + Oauth, + Operation, + Pattern, + PerformanceFeedback, + Portfolio, + Preview, + PrimaryCountry, + Principal, + PropagationSurface, + PropertyFeature, + ProposalRefinement, + ProtocolMethodsRequiredForItem, + ProtocolMethodsSupportedForItem, + ProtocolMethodsWarnForItem, + PublisherDomain, + Range, + RegistrationTask, + RelationshipNotifications, + RenderingOrigin, + ReportingDestinationOffering, + RepresentationResolution, + RequestSigning, + RequiredForItem, + Requirement, + ResourceType, + RightsAttestations, + Route, + RuntimeAttestations, + SignalActivation, + Signals, + SponsoredIntelligence, + SupportedChange, + SupportedDimension, + SupportedForItem, + SupportedFormat, + SupportedIdentifierType, + SupportedOptimizationMetric, + SupportedPresenceMode, + SupportedProtocol, + SupportedRequirementMode, + SupportedScope, + SupportedSection, + SupportedTarget, + SupportedTarget1, + SupportedVersion, + Targeting, + Tasks, + Tasks1, + Tasks2, + Tasks3, + Tasks4, + Tasks5, + Tasks6, + Tasks7, + Tasks8, + Tasks9, + Transport, + Transport1, + TrustedMatch, + Type6, + VariantDimension, + VastValidation, + VendorMetricOptimization, + WarnForItem, + WebhookSigning, + WholesaleFeedVersioning, + WholesaleFeedWebhooks, + ) + from adcp.types.domains.protocol.get_principal_request import GetPrincipalRequest + from adcp.types.domains.protocol.get_principal_response import ( + GetPrincipalResponse, + PrincipalCurrentResult, + PrincipalReadFailedResult, + PrincipalRecognizedResult, + PrincipalUnconfiguredResult, + Result6, + Result7, + Result9, + ) + from adcp.types.domains.protocol.get_task_status_request import GetTaskStatusRequest + from adcp.types.domains.protocol.get_task_status_response import ( + Details, + Error, + GetTaskStatusResponse, + HistoryItem, + Progress, + ) + from adcp.types.domains.protocol.list_tasks_request import ( + Field1, + Filters, + ListTasksRequest, + Sort, + ) + from adcp.types.domains.protocol.list_tasks_response import ( + Direction, + Domain, + DomainBreakdown, + ListTasksResponse, + QuerySummary, + SortApplied, + Task, + ) + from adcp.types.domains.protocol.sync_agent_notification_configs_request import ( + SyncAgentNotificationConfigsRequest, + ) + from adcp.types.domains.protocol.sync_agent_notification_configs_response import ( + SyncAgentNotificationConfigsResponse, + ) + from adcp.types.domains.protocol.sync_principal_request import ( + Configuration, + SyncPrincipalRequest, + ) + from adcp.types.domains.protocol.sync_principal_response import ( + Action33, + PrincipalAppliedResult, + PrincipalSyncFailedResult, + PrincipalValidatedResult, + Result17, + Result19, + SyncPrincipalResponse, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "protocol.get_adcp_capabilities_request": ( + "GetAdcpCapabilitiesRequest", + "Protocol", + ), + "protocol.get_adcp_capabilities_response": ( + "AcceptancePolicyDiscovery", + "AccessMode", + "Account", + "Accreditation", + "Adcp", + "AgeRestriction", + "Algorithm", + "AttributionWindow", + "AudienceEvidence", + "AudienceTargeting", + "Brand", + "BudgetCapping", + "BudgetCappingTimezoneBasis", + "BuyingMode", + "CapabilityChanges", + "CapabilityReportingDeliveryMethod", + "ChangeFeed", + "ChangeFeed1", + "ComplianceTesting", + "CompromiseNotification", + "ContentStandards", + "ConversionTracking", + "CoversContentDigest", + "Creative", + "CreativeApprovalMode", + "CreativeFeature", + "CreativeSpecs", + "DataProviderDomain", + "DefaultProfileId", + "Demographics", + "DiscoveryMode", + "Endpoint", + "EventType", + "EventType3", + "Execution", + "ExtensionsSupportedItem", + "Features", + "Format", + "FrequencyCapping", + "GeoMetros", + "GeoProximity", + "GetAdcpCapabilitiesResponse", + "Governance", + "GovernanceEnforcement", + "Idempotency", + "Idempotency1", + "Identity", + "IdentityUpdates", + "IdentityUpdates1", + "KeyOrigins", + "KeywordTargets", + "Language", + "LifecycleTool", + "Localization", + "MajorVersion", + "MatchingLatencyHours", + "Measurement", + "MeasurementGateway", + "MediaBuy", + "Metric", + "Mode", + "MraidVersion", + "Multiplicity", + "NegativeKeywords", + "Notifications", + "Notifications1", + "Notifications2", + "Notifications3", + "Oauth", + "Operation", + "Pattern", + "PerformanceFeedback", + "Portfolio", + "Preview", + "PrimaryCountry", + "Principal", + "PropagationSurface", + "PropertyFeature", + "ProposalRefinement", + "ProtocolMethodsRequiredForItem", + "ProtocolMethodsSupportedForItem", + "ProtocolMethodsWarnForItem", + "PublisherDomain", + "Range", + "RegistrationTask", + "RelationshipNotifications", + "RenderingOrigin", + "ReportingDestinationOffering", + "RepresentationResolution", + "RequestSigning", + "RequiredForItem", + "Requirement", + "ResourceType", + "RightsAttestations", + "Route", + "RuntimeAttestations", + "SignalActivation", + "Signals", + "SponsoredIntelligence", + "SupportedChange", + "SupportedDimension", + "SupportedForItem", + "SupportedFormat", + "SupportedIdentifierType", + "SupportedOptimizationMetric", + "SupportedPresenceMode", + "SupportedProtocol", + "SupportedRequirementMode", + "SupportedScope", + "SupportedSection", + "SupportedTarget", + "SupportedTarget1", + "SupportedVersion", + "Targeting", + "Tasks", + "Tasks1", + "Tasks2", + "Tasks3", + "Tasks4", + "Tasks5", + "Tasks6", + "Tasks7", + "Tasks8", + "Tasks9", + "Transport", + "Transport1", + "TrustedMatch", + "Type6", + "VariantDimension", + "VastValidation", + "VendorMetricOptimization", + "WarnForItem", + "WebhookSigning", + "WholesaleFeedVersioning", + "WholesaleFeedWebhooks", + ), + "protocol.get_principal_request": ("GetPrincipalRequest",), + "protocol.get_principal_response": ( + "GetPrincipalResponse", + "PrincipalCurrentResult", + "PrincipalReadFailedResult", + "PrincipalRecognizedResult", + "PrincipalUnconfiguredResult", + "Result6", + "Result7", + "Result9", + ), + "protocol.get_task_status_request": ("GetTaskStatusRequest",), + "protocol.get_task_status_response": ( + "Details", + "Error", + "GetTaskStatusResponse", + "HistoryItem", + "Progress", + ), + "protocol.list_tasks_request": ( + "Field1", + "Filters", + "ListTasksRequest", + "Sort", + ), + "protocol.list_tasks_response": ( + "Direction", + "Domain", + "DomainBreakdown", + "ListTasksResponse", + "QuerySummary", + "SortApplied", + "Task", + ), + "protocol.sync_agent_notification_configs_request": ("SyncAgentNotificationConfigsRequest",), + "protocol.sync_agent_notification_configs_response": ("SyncAgentNotificationConfigsResponse",), + "protocol.sync_principal_request": ( + "Configuration", + "SyncPrincipalRequest", + ), + "protocol.sync_principal_response": ( + "Action33", + "PrincipalAppliedResult", + "PrincipalSyncFailedResult", + "PrincipalValidatedResult", + "Result17", + "Result19", + "SyncPrincipalResponse", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py b/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py index 5a9cbd16c..c6e608576 100644 --- a/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py +++ b/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_adcp_capabilities_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -11,7 +11,6 @@ from typing import Any, Annotated, Dict, Literal from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey -from adcp.types._scalar import ScalarInt, ScalarStr from pydantic import ( AnyUrl, AwareDatetime, @@ -20,6 +19,7 @@ RootModel, StrictBool, StrictFloat, + model_validator, ) from ..core import account_timezone_capability, attestation_capabilities, audience_activation_method @@ -70,6 +70,13 @@ from ..sponsored_intelligence import si_capabilities +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarInt, ScalarStr + + +from pydantic_core import PydanticCustomError + + class MajorVersion(ScalarInt): __slots__ = () _constraints = {'ge': 1} @@ -2745,3 +2752,141 @@ class GetAdcpCapabilitiesResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv description='Per-agent wholesale product-feed and wholesale signals-feed webhook capabilities. Consumers register durable sync_accounts notification subscribers and receive actual product.*, signal.*, or wholesale_feed.bulk_change payloads without polling. Product mirrors bootstrap and repair through list_products(if_feed_version); signal mirrors use get_signals(if_wholesale_feed_version). Deprecated wholesale get_products remains the 3.x product compatibility path. Webhook emission MUST apply the same caller/account authorization and cache-scope predicate as the corresponding read.' ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetAdcpCapabilitiesResponse: + # Compiled from protocol/get-adcp-capabilities-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'measurement.gateway')) and _c.descend(self, 'supported_protocols', lambda supported_protocols: _c.some(supported_protocols, lambda item: item == 'measurement'))) and _c.sent(self, 'experimental_features')) if _c.sent(self, 'measurement_gateway') else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 0) over experimental_features, measurement_gateway, supported_protocols', + {'fields': 'experimental_features, measurement_gateway, supported_protocols'}, + ) + if not ( + (((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'measurement.core')) and _c.descend(self, 'supported_protocols', lambda supported_protocols: _c.some(supported_protocols, lambda item: item == 'measurement'))) and _c.sent(self, 'experimental_features')) if _c.sent(self, 'measurement') else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 1) over experimental_features, measurement, supported_protocols', + {'fields': 'experimental_features, measurement, supported_protocols'}, + ) + if not ( + ((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'measurement.core')) and _c.sent(self, 'experimental_features')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.sent(media_buy, 'performance_feedback')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 2) over experimental_features, media_buy, performance_feedback', + {'fields': 'experimental_features, media_buy, performance_feedback'}, + ) + if not ( + ((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'protocol.principal')) and _c.sent(self, 'experimental_features')) if (_c.descend(self, 'adcp', lambda adcp: _c.sent(adcp, 'principal')) and _c.sent(self, 'adcp')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 3) over adcp, experimental_features, principal', + {'fields': 'adcp, experimental_features, principal'}, + ) + if not ( + (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'capability_changes', lambda capability_changes: _c.descend(capability_changes, 'notifications', lambda notifications: (_c.descend(notifications, 'registration_task', lambda registration_task: registration_task == 'sync_principal') and _c.descend(notifications, 'supported', lambda supported: supported == True)) and (_c.sent(notifications, 'supported') and _c.sent(notifications, 'registration_task'))) and _c.sent(capability_changes, 'notifications')) and _c.sent(adcp, 'capability_changes')) if (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'principal', lambda principal: _c.descend(principal, 'supported_sections', lambda supported_sections: _c.some(supported_sections, lambda item: item == 'notification_configs')) and _c.sent(principal, 'supported_sections')) and _c.sent(adcp, 'principal')) and _c.sent(self, 'adcp')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 4) over adcp, capability_changes, notifications, principal, registration_task, supported, supported_sections', + {'fields': 'adcp, capability_changes, notifications, principal, registration_task, supported, supported_sections'}, + ) + if not ( + (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'principal', lambda principal: _c.descend(principal, 'supported_sections', lambda supported_sections: _c.some(supported_sections, lambda item: item == 'notification_configs')) and _c.sent(principal, 'supported_sections')) and _c.sent(adcp, 'principal')) if (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'capability_changes', lambda capability_changes: _c.descend(capability_changes, 'notifications', lambda notifications: _c.descend(notifications, 'registration_task', lambda registration_task: registration_task == 'sync_principal') and _c.sent(notifications, 'registration_task')) and _c.sent(capability_changes, 'notifications')) and _c.sent(adcp, 'capability_changes')) and _c.sent(self, 'adcp')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 5) over adcp, capability_changes, notifications, principal, registration_task, supported_sections', + {'fields': 'adcp, capability_changes, notifications, principal, registration_task, supported_sections'}, + ) + if not ( + ((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'media_buy.reporting_delivery')) and _c.sent(self, 'experimental_features')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.sent(media_buy, 'reporting_delivery')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 6) over experimental_features, media_buy, reporting_delivery', + {'fields': 'experimental_features, media_buy, reporting_delivery'}, + ) + if not ( + ((_c.descend(self, 'webhook_signing', lambda webhook_signing: _c.descend(webhook_signing, 'supported', lambda supported: supported == True) and (_c.sent(webhook_signing, 'supported') and _c.sent(webhook_signing, 'profile') and _c.sent(webhook_signing, 'algorithms') and _c.sent(webhook_signing, 'legacy_hmac_fallback'))) and _c.sent(self, 'webhook_signing')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.descend(media_buy, 'reporting_delivery', lambda reporting_delivery: _c.sent(reporting_delivery, 'readiness_notification') or _c.sent(reporting_delivery, 'status_notification') or _c.sent(reporting_delivery, 'ledger_notification')) and _c.sent(media_buy, 'reporting_delivery')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 7) over algorithms, ledger_notification, legacy_hmac_fallback, media_buy, profile, readiness_notification, reporting_delivery, status_notification, supported, webhook_signing', + {'fields': 'algorithms, ledger_notification, legacy_hmac_fallback, media_buy, profile, readiness_notification, reporting_delivery, status_notification, supported, webhook_signing'}, + ) + if not ( + ((_c.descend(self, 'experimental_features', lambda experimental_features: _c.some(experimental_features, lambda item: item == 'media_buy.audience_activation')) and _c.sent(self, 'experimental_features')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.descend(media_buy, 'audience_targeting', lambda audience_targeting: _c.sent(audience_targeting, 'supported_activation_methods')) and _c.sent(media_buy, 'audience_targeting')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 8) over audience_targeting, experimental_features, media_buy, supported_activation_methods', + {'fields': 'audience_targeting, experimental_features, media_buy, supported_activation_methods'}, + ) + if not ( + (_c.descend(self, 'request_signing', lambda request_signing: _c.descend(request_signing, 'covers_content_digest', lambda covers_content_digest: covers_content_digest == 'required') and _c.sent(request_signing, 'covers_content_digest')) if ((_c.descend(self, 'request_signing', lambda request_signing: _c.descend(request_signing, 'supported', lambda supported: supported == True)) and _c.sent(self, 'request_signing')) and ((_c.descend(self, 'adcp_version', lambda adcp_version: _c.matches(adcp_version, '^(?:3\\.(?:[2-9]|[1-9][0-9]+)|(?:[4-9]|[1-9][0-9]+)\\.\\d+)(?:-[a-zA-Z0-9.-]+)?$') and _c.json_type(adcp_version, 'string')) and _c.sent(self, 'adcp_version')) or (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'supported_versions', lambda supported_versions: _c.some(supported_versions, lambda item: _c.matches(item, '^(?:3\\.(?:[2-9]|[1-9][0-9]+)|(?:[4-9]|[1-9][0-9]+)\\.\\d+)(?:-[a-zA-Z0-9.-]+)?$') and _c.json_type(item, 'string'))) and _c.sent(adcp, 'supported_versions')) and _c.sent(self, 'adcp')))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 9) over adcp, adcp_version, covers_content_digest, request_signing, supported, supported_versions', + {'fields': 'adcp, adcp_version, covers_content_digest, request_signing, supported, supported_versions'}, + ) + if not ( + (_c.descend(self, 'adcp', lambda adcp: _c.sent(adcp, 'attestations')) if (_c.descend(self, 'governance', lambda governance: _c.sent(governance, 'runtime_attestations')) and _c.sent(self, 'governance')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 10) over adcp, attestations, governance, runtime_attestations', + {'fields': 'adcp, attestations, governance, runtime_attestations'}, + ) + if not ( + (_c.descend(self, 'adcp', lambda adcp: _c.sent(adcp, 'attestations')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.descend(media_buy, 'audience_evidence', lambda audience_evidence: _c.descend(audience_evidence, 'supports_attestation_evaluation', lambda supports_attestation_evaluation: supports_attestation_evaluation == True) and _c.sent(audience_evidence, 'supports_attestation_evaluation')) and _c.sent(media_buy, 'audience_evidence')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 11) over adcp, attestations, audience_evidence, media_buy, supports_attestation_evaluation', + {'fields': 'adcp, attestations, audience_evidence, media_buy, supports_attestation_evaluation'}, + ) + if not ( + (_c.descend(self, 'adcp', lambda adcp: _c.descend(adcp, 'attestations', lambda attestations: _c.descend(attestations, 'accepted_claim_types', lambda accepted_claim_types: _c.some(accepted_claim_types, lambda item: item == 'https://adcontextprotocol.org/claims/rights/grant'))) and _c.sent(adcp, 'attestations')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.sent(media_buy, 'rights_attestations')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 12) over accepted_claim_types, adcp, attestations, media_buy, rights_attestations', + {'fields': 'accepted_claim_types, adcp, attestations, media_buy, rights_attestations'}, + ) + if not ( + ((_c.descend(self, 'webhook_signing', lambda webhook_signing: _c.descend(webhook_signing, 'supported', lambda supported: supported == True) and (_c.sent(webhook_signing, 'supported') and _c.sent(webhook_signing, 'profile') and _c.sent(webhook_signing, 'algorithms') and _c.sent(webhook_signing, 'legacy_hmac_fallback'))) and _c.sent(self, 'webhook_signing')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.sent(media_buy, 'relationship_notifications')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 13) over algorithms, legacy_hmac_fallback, media_buy, profile, relationship_notifications, supported, webhook_signing', + {'fields': 'algorithms, legacy_hmac_fallback, media_buy, profile, relationship_notifications, supported, webhook_signing'}, + ) + if not ( + ((_c.descend(self, 'supported_protocols', lambda supported_protocols: _c.some(supported_protocols, lambda item: item == 'creative')) and _c.sent(self, 'supported_protocols')) if (_c.descend(self, 'media_buy', lambda media_buy: _c.descend(media_buy, 'relationship_notifications', lambda relationship_notifications: _c.descend(relationship_notifications, 'projection_tasks', lambda projection_tasks: _c.some(projection_tasks, lambda item: item == 'list_creatives')) and _c.sent(relationship_notifications, 'projection_tasks')) and _c.sent(media_buy, 'relationship_notifications')) and _c.sent(self, 'media_buy')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 14) over media_buy, projection_tasks, relationship_notifications, supported_protocols', + {'fields': 'media_buy, projection_tasks, relationship_notifications, supported_protocols'}, + ) + if not ( + (not (True) if ((_c.descend(self, 'account', lambda account: _c.descend(account, 'required_for_products', lambda required_for_products: required_for_products == True) and _c.sent(account, 'required_for_products')) and _c.descend(self, 'media_buy', lambda media_buy: _c.descend(media_buy, 'anonymous_discovery', lambda anonymous_discovery: anonymous_discovery == True) and _c.sent(media_buy, 'anonymous_discovery'))) and (_c.sent(self, 'media_buy') and _c.sent(self, 'account'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetAdcpCapabilitiesResponse violates a constraint its schema states outside its properties (protocol/get-adcp-capabilities-response.json, root conjunct 15) over account, anonymous_discovery, media_buy, required_for_products', + {'fields': 'account, anonymous_discovery, media_buy, required_for_products'}, + ) + return self diff --git a/src/adcp/types/domains/protocol/get_principal_request.py b/src/adcp/types/domains/protocol/get_principal_request.py index 68750f185..c0441c9c0 100644 --- a/src/adcp/types/domains/protocol/get_principal_request.py +++ b/src/adcp/types/domains/protocol/get_principal_request.py @@ -1,21 +1,45 @@ # generated by datamodel-codegen: # filename: protocol/get_principal_request.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest -from pydantic import ConfigDict +from pydantic import ConfigDict, model_validator from ..core import context as context_1 from ..core import ext as ext_1 from ..core.version_envelope import AdcpVersionEnvelope +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class GetPrincipalRequest(AdcpRequest, AdcpVersionEnvelope): model_config = ConfigDict( extra='allow', ) context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetPrincipalRequest: + # Compiled from protocol/get-principal-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'buyer_agent_url') or _c.supplied(self, 'agent_url') or _c.supplied(self, 'principal_id') or _c.supplied(self, 'connection_id')) + ): + raise PydanticCustomError( + 'allOf', + 'GetPrincipalRequest violates a constraint its schema states outside its properties (protocol/get-principal-request.json, root conjunct 0) over agent_url, buyer_agent_url, connection_id, principal_id', + {'fields': 'agent_url, buyer_agent_url, connection_id, principal_id'}, + ) + return self diff --git a/src/adcp/types/domains/protocol/get_principal_response.py b/src/adcp/types/domains/protocol/get_principal_response.py index df22a70a3..bf7a0dc1b 100644 --- a/src/adcp/types/domains/protocol/get_principal_response.py +++ b/src/adcp/types/domains/protocol/get_principal_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_principal_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import error @@ -20,6 +20,12 @@ from ..enums import principal_kind as principal_kind_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Result(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -93,9 +99,27 @@ class GetPrincipalResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): result: Result6 | Result7 | Result | Result9 context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None - - -# Stable aliases for anonymous result arms (selected by discriminator). + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetPrincipalResponse: + # Compiled from protocol/get-principal-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'status', lambda status: status in ['failed', 'rejected']) if _c.descend(self, 'result', lambda result: _c.descend(result, 'kind', lambda kind: kind == 'failed') and _c.sent(result, 'kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetPrincipalResponse violates a constraint its schema states outside its properties (protocol/get-principal-response.json, root conjunct 0) over kind, result, status', + {'fields': 'kind, result, status'}, + ) + return self + + +# Stable aliases for anonymous result branches (selected by discriminator). PrincipalUnconfiguredResult = Result PrincipalCurrentResult = Result6 PrincipalRecognizedResult = Result7 diff --git a/src/adcp/types/domains/protocol/get_task_status_response.py b/src/adcp/types/domains/protocol/get_task_status_response.py index 596218463..487ce9176 100644 --- a/src/adcp/types/domains/protocol/get_task_status_response.py +++ b/src/adcp/types/domains/protocol/get_task_status_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_task_status_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import context as context_1 from ..core import ext as ext_1 @@ -20,6 +20,12 @@ from ..enums import task_type as task_type_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Progress(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -122,3 +128,21 @@ class GetTaskStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetTaskStatusResponse: + # Compiled from protocol/get-task-status-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'result', lambda result: ((_c.descend(result, 'adcp_major_version', lambda adcp_major_version: _c.within(adcp_major_version, le=99) and _c.within(adcp_major_version, ge=1) and _c.json_type(adcp_major_version, 'integer')) and _c.descend(result, 'adcp_version', lambda adcp_version: _c.matches(adcp_version, '^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$') and _c.json_type(adcp_version, 'string'))) and (not (_c.supplied(result, 'task_status') or _c.supplied(result, 'response_status')) and (_c.descend(result, 'adcp_error', lambda adcp_error: (not (_c.supplied(adcp_error, 'buyer_reason')) or _c.supplied(adcp_error, 'recovery')) and (_c.descend(adcp_error, 'buyer_reason', lambda buyer_reason: (_c.descend(buyer_reason, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(buyer_reason, 'message', lambda message: _c.length(message, minimum=1) and _c.json_type(message, 'string'))) and (_c.supplied(buyer_reason, 'code') and _c.supplied(buyer_reason, 'message')) and _c.json_type(buyer_reason, 'object')) and _c.descend(adcp_error, 'code', lambda code: _c.length(code, maximum=64) and _c.length(code, minimum=1) and _c.json_type(code, 'string')) and _c.descend(adcp_error, 'details', lambda details: _c.json_type(details, 'object')) and _c.descend(adcp_error, 'field', lambda field: _c.json_type(field, 'string')) and _c.descend(adcp_error, 'issues', lambda issues: _c.every(issues, lambda item: (_c.descend(item, 'discriminator', lambda discriminator: _c.every(discriminator, lambda item: _c.only(item, ['property_name', 'value']) and (_c.descend(item, 'property_name', lambda property_name: _c.json_type(property_name, 'string')) and _c.descend(item, 'value', lambda value: _c.json_type(value, 'string', 'number', 'boolean', 'null'))) and (_c.supplied(item, 'property_name') and _c.supplied(item, 'value')) and _c.json_type(item, 'object')) and _c.json_type(discriminator, 'array')) and _c.descend(item, 'keyword', lambda keyword: _c.json_type(keyword, 'string')) and _c.descend(item, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(item, 'pointer', lambda pointer: _c.json_type(pointer, 'string')) and _c.descend(item, 'schemaPath', lambda schemaPath: _c.json_type(schemaPath, 'string')) and _c.descend(item, 'schema_id', lambda schema_id: _c.json_type(schema_id, 'string'))) and (_c.supplied(item, 'pointer') and _c.supplied(item, 'message') and _c.supplied(item, 'keyword')) and _c.json_type(item, 'object')) and _c.json_type(issues, 'array')) and _c.descend(adcp_error, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(adcp_error, 'recovery', lambda recovery: recovery in ['transient', 'correctable', 'terminal'] and _c.json_type(recovery, 'string')) and _c.descend(adcp_error, 'retry_after', lambda retry_after: _c.within(retry_after, le=3600) and _c.within(retry_after, ge=1) and _c.json_type(retry_after, 'number')) and _c.descend(adcp_error, 'sdk_id', lambda sdk_id: _c.json_type(sdk_id, 'string')) and _c.descend(adcp_error, 'source', lambda source: source in ['producer', 'sdk'] and _c.json_type(source, 'string')) and _c.descend(adcp_error, 'suggestion', lambda suggestion: _c.json_type(suggestion, 'string'))) and (_c.supplied(adcp_error, 'code') and _c.supplied(adcp_error, 'message')) and _c.json_type(adcp_error, 'object')) and _c.descend(result, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(result, 'context_id', lambda context_id: _c.json_type(context_id, 'string')) and _c.descend(result, 'governance_context', lambda governance_context: _c.length(governance_context, maximum=4096) and _c.length(governance_context, minimum=1) and _c.matches(governance_context, '^[\\x20-\\x7E]+$') and _c.json_type(governance_context, 'string')) and _c.descend(result, 'message', lambda message: _c.json_type(message, 'string')) and _c.descend(result, 'payload', lambda payload: _c.json_type(payload, 'object')) and _c.descend(result, 'push_notification_config', lambda push_notification_config: (_c.descend(push_notification_config, 'authentication', lambda authentication: _c.only(authentication, ['credentials', 'schemes']) and (_c.descend(authentication, 'credentials', lambda credentials: _c.length(credentials, minimum=32) and _c.json_type(credentials, 'string')) and _c.descend(authentication, 'schemes', lambda schemes: _c.every(schemes, lambda item: item in ['Bearer', 'HMAC-SHA256'] and _c.json_type(item, 'string')) and _c.length(schemes, maximum=1) and _c.length(schemes, minimum=1) and _c.json_type(schemes, 'array'))) and (_c.supplied(authentication, 'schemes') and _c.supplied(authentication, 'credentials')) and _c.json_type(authentication, 'object')) and _c.descend(push_notification_config, 'operation_id', lambda operation_id: _c.length(operation_id, maximum=255) and _c.length(operation_id, minimum=1) and _c.matches(operation_id, '^[A-Za-z0-9_.:-]{1,255}$') and _c.json_type(operation_id, 'string')) and _c.descend(push_notification_config, 'token', lambda token: _c.length(token, maximum=4096) and _c.length(token, minimum=16) and _c.json_type(token, 'string')) and _c.descend(push_notification_config, 'url', lambda url: _c.json_type(url, 'string'))) and _c.supplied(push_notification_config, 'url') and _c.json_type(push_notification_config, 'object')) and _c.descend(result, 'replayed', lambda replayed: _c.json_type(replayed, 'boolean')) and _c.descend(result, 'status', lambda status: status in ['submitted', 'working', 'input-required', 'completed', 'canceled', 'failed', 'rejected', 'auth-required', 'unknown'] and _c.json_type(status, 'string')) and _c.descend(result, 'task_id', lambda task_id: _c.json_type(task_id, 'string')) and _c.descend(result, 'timestamp', lambda timestamp: _c.json_type(timestamp, 'string'))) and _c.supplied(result, 'status'))) and not (_c.supplied(result, 'products') or _c.supplied(result, 'proposals') or _c.supplied(result, 'errors') or _c.supplied(result, 'adcp_error') or _c.supplied(result, 'incomplete') or _c.supplied(result, 'cache_scope') or _c.supplied(result, 'filter_diagnostics') or _c.supplied(result, 'refinement_applied') or _c.supplied(result, 'unchanged') or _c.supplied(result, 'wholesale_feed_version') or _c.supplied(result, 'pricing_version') or _c.supplied(result, 'pagination')) and (_c.descend(result, 'context', lambda context: _c.json_type(context, 'object')) and _c.descend(result, 'ext', lambda ext: _c.json_type(ext, 'object')) and _c.descend(result, 'reason', lambda reason: _c.length(reason, maximum=2000) and _c.length(reason, minimum=1) and _c.json_type(reason, 'string')) and _c.descend(result, 'status', lambda status: status == 'rejected' and _c.json_type(status, 'string')) and _c.descend(result, 'suggestions', lambda suggestions: _c.every(suggestions, lambda item: _c.length(item, maximum=1000) and _c.length(item, minimum=1) and _c.json_type(item, 'string')) and _c.length(suggestions, maximum=20) and _c.length(suggestions, minimum=1) and _c.json_type(suggestions, 'array'))) and (_c.supplied(result, 'status') and _c.supplied(result, 'reason'))) if ((_c.descend(self, 'result', lambda result: _c.descend(result, 'status', lambda status: status == 'rejected') and _c.supplied(result, 'status')) and _c.descend(self, 'task_type', lambda task_type: task_type == 'get_products')) and (_c.sent(self, 'task_type') and _c.sent(self, 'result'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetTaskStatusResponse violates a constraint its schema states outside its properties (protocol/get-task-status-response.json, root conjunct 0) over adcp_error, adcp_major_version, adcp_version, authentication, buyer_reason, cache_scope, code, context, context_id, credentials, details, discriminator, errors, ext, field, filter_diagnostics, governance_context, incomplete, issues, keyword, message, operation_id, pagination, payload, pointer, pricing_version, products, property_name, proposals, push_notification_config, reason, recovery, refinement_applied, replayed, response_status, result, retry_after, schemaPath, schema_id, schemes, sdk_id, source, status, suggestion, suggestions, task_id, task_status, task_type, timestamp, token, unchanged, url, value, wholesale_feed_version', + {'fields': 'adcp_error, adcp_major_version, adcp_version, authentication, buyer_reason, cache_scope, code, context, context_id, credentials, details, discriminator, errors, ext, field, filter_diagnostics, governance_context, incomplete, issues, keyword, message, operation_id, pagination, payload, pointer, pricing_version, products, property_name, proposals, push_notification_config, reason, recovery, refinement_applied, replayed, response_status, result, retry_after, schemaPath, schema_id, schemes, sdk_id, source, status, suggestion, suggestions, task_id, task_status, task_type, timestamp, token, unchanged, url, value, wholesale_feed_version'}, + ) + return self diff --git a/src/adcp/types/domains/protocol/sync_principal_request.py b/src/adcp/types/domains/protocol/sync_principal_request.py index dd4d850c0..8664b0675 100644 --- a/src/adcp/types/domains/protocol/sync_principal_request.py +++ b/src/adcp/types/domains/protocol/sync_principal_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/sync_principal_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import agent_notification_config, agent_reporting_destination from ..core import context as context_1 @@ -19,6 +19,12 @@ from ..enums import principal_kind +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Configuration(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -86,3 +92,21 @@ class SyncPrincipalRequest(AdcpRequest, AdcpVersionEnvelope): ] = False context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> SyncPrincipalRequest: + # Compiled from protocol/sync-principal-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'buyer_agent_url') or _c.supplied(self, 'agent_url') or _c.supplied(self, 'principal_id') or _c.supplied(self, 'connection_id')) + ): + raise PydanticCustomError( + 'allOf', + 'SyncPrincipalRequest violates a constraint its schema states outside its properties (protocol/sync-principal-request.json, root conjunct 0) over agent_url, buyer_agent_url, connection_id, principal_id', + {'fields': 'agent_url, buyer_agent_url, connection_id, principal_id'}, + ) + return self diff --git a/src/adcp/types/domains/protocol/sync_principal_response.py b/src/adcp/types/domains/protocol/sync_principal_response.py index 8f069dd8f..d80b9fe54 100644 --- a/src/adcp/types/domains/protocol/sync_principal_response.py +++ b/src/adcp/types/domains/protocol/sync_principal_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/sync_principal_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import error @@ -21,6 +21,12 @@ from ..enums import principal_kind as principal_kind_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Action(StrEnum): updated = 'updated' unchanged = 'unchanged' @@ -96,9 +102,27 @@ class SyncPrincipalResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) result: Result17 | Result | Result19 context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None - - -# Stable aliases for anonymous result arms (selected by discriminator). + @model_validator(mode='after') + def _require_schema_required_group(self) -> SyncPrincipalResponse: + # Compiled from protocol/sync-principal-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'status', lambda status: status in ['failed', 'rejected']) if _c.descend(self, 'result', lambda result: _c.descend(result, 'kind', lambda kind: kind == 'failed') and _c.sent(result, 'kind')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SyncPrincipalResponse violates a constraint its schema states outside its properties (protocol/sync-principal-response.json, root conjunct 0) over kind, result, status', + {'fields': 'kind, result, status'}, + ) + return self + + +# Stable aliases for anonymous result branches (selected by discriminator). PrincipalValidatedResult = Result PrincipalAppliedResult = Result17 PrincipalSyncFailedResult = Result19 diff --git a/src/adcp/types/domains/registries/__init__.py b/src/adcp/types/domains/registries/__init__.py index 133424d84..9642959f0 100644 --- a/src/adcp/types/domains/registries/__init__.py +++ b/src/adcp/types/domains/registries/__init__.py @@ -9,24 +9,78 @@ it from its own schema's module, ``adcp.types.domains.registries.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.registries.v1_canonical_mapping import ( - Dimensions, - Mapping, - ParameterMapping, - Structural, - Transform, - V1Pattern, - V1Pattern1, - V1V2CanonicalFormatMappingRegistry, - V2, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.registries.v1_canonical_mapping import ( + Dimensions, + Mapping, + ParameterMapping, + Structural, + Transform, + V1Pattern, + V1Pattern1, + V1V2CanonicalFormatMappingRegistry, + V2, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "registries.v1_canonical_mapping": ( + "Dimensions", + "Mapping", + "ParameterMapping", + "Structural", + "Transform", + "V1Pattern", + "V1Pattern1", + "V1V2CanonicalFormatMappingRegistry", + "V2", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/signals/__init__.py b/src/adcp/types/domains/signals/__init__.py index 6d256e076..7e55aeecf 100644 --- a/src/adcp/types/domains/signals/__init__.py +++ b/src/adcp/types/domains/signals/__init__.py @@ -9,50 +9,141 @@ it from its own schema's module, ``adcp.types.domains.signals.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.signals.activate_signal_request import Action, ActivateSignalRequest -from adcp.types.domains.signals.activate_signal_response import ( - ActivateSignalResponse, - ActivateSignalResponse1, - ActivateSignalResponse2, -) -from adcp.types.domains.signals.get_signals_async_response_submitted import GetSignalsSubmitted -from adcp.types.domains.signals.get_signals_async_response_working import GetSignalsWorking -from adcp.types.domains.signals.get_signals_request import DiscoveryMode, Field1, GetSignalsRequest -from adcp.types.domains.signals.get_signals_response import ( - AiActRiskClass, - Art9Basis, - CacheScope, - Channel, - DataSource, - DataSubjectRights, - GetSignalsResponse, - IncompleteItem, - MatchKey, - Method, - Methodology, - Modeling, - Onboarder, - ParentMatchBehavior, - PreOnboardingPrecisionLevel, - Range, - RefreshCadence, - Right, - Scope, - SeedSource, - Signal, - Taxonomy, - TrainingDataJurisdiction, - Type, - Value, - ValueMapping, -) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.signals.activate_signal_request import Action, ActivateSignalRequest + from adcp.types.domains.signals.activate_signal_response import ( + ActivateSignalResponse, + ActivateSignalResponse1, + ActivateSignalResponse2, + ) + from adcp.types.domains.signals.get_signals_async_response_submitted import GetSignalsSubmitted + from adcp.types.domains.signals.get_signals_async_response_working import GetSignalsWorking + from adcp.types.domains.signals.get_signals_request import ( + DiscoveryMode, + Field1, + GetSignalsRequest, + ) + from adcp.types.domains.signals.get_signals_response import ( + AiActRiskClass, + Art9Basis, + CacheScope, + Channel, + DataSource, + DataSubjectRights, + GetSignalsResponse, + IncompleteItem, + MatchKey, + Method, + Methodology, + Modeling, + Onboarder, + ParentMatchBehavior, + PreOnboardingPrecisionLevel, + Range, + RefreshCadence, + Right, + Scope, + SeedSource, + Signal, + Taxonomy, + TrainingDataJurisdiction, + Type, + Value, + ValueMapping, + ) + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "signals.activate_signal_request": ( + "Action", + "ActivateSignalRequest", + ), + "signals.activate_signal_response": ( + "ActivateSignalResponse", + "ActivateSignalResponse1", + "ActivateSignalResponse2", + ), + "signals.get_signals_async_response_submitted": ("GetSignalsSubmitted",), + "signals.get_signals_async_response_working": ("GetSignalsWorking",), + "signals.get_signals_request": ( + "DiscoveryMode", + "Field1", + "GetSignalsRequest", + ), + "signals.get_signals_response": ( + "AiActRiskClass", + "Art9Basis", + "CacheScope", + "Channel", + "DataSource", + "DataSubjectRights", + "GetSignalsResponse", + "IncompleteItem", + "MatchKey", + "Method", + "Methodology", + "Modeling", + "Onboarder", + "ParentMatchBehavior", + "PreOnboardingPrecisionLevel", + "Range", + "RefreshCadence", + "Right", + "Scope", + "SeedSource", + "Signal", + "Taxonomy", + "TrainingDataJurisdiction", + "Type", + "Value", + "ValueMapping", + ), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/signals/get_signals_async_response_submitted.py b/src/adcp/types/domains/signals/get_signals_async_response_submitted.py index 22488e68a..394c40c3d 100644 --- a/src/adcp/types/domains/signals/get_signals_async_response_submitted.py +++ b/src/adcp/types/domains/signals/get_signals_async_response_submitted.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: signals/get_signals_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import error from ..core import ext as ext_1 +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class GetSignalsSubmitted(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -49,3 +55,21 @@ class GetSignalsSubmitted(AdCPBaseModel): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetSignalsSubmitted: + # Compiled from signals/get-signals-async-response-submitted.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'signals') or _c.supplied(self, 'result')) + ): + raise PydanticCustomError( + 'allOf', + 'GetSignalsSubmitted violates a constraint its schema states outside its properties (signals/get-signals-async-response-submitted.json, root conjunct 0) over result, signals', + {'fields': 'result, signals'}, + ) + return self diff --git a/src/adcp/types/domains/signals/get_signals_request.py b/src/adcp/types/domains/signals/get_signals_request.py index cc807a636..a95176cc7 100644 --- a/src/adcp/types/domains/signals/get_signals_request.py +++ b/src/adcp/types/domains/signals/get_signals_request.py @@ -1,17 +1,15 @@ # generated by datamodel-codegen: # filename: signals/get_signals_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest, SchemaInt -from adcp.types._scalar import ScalarStr - from adcp.types._str_enum import StrEnum from typing import Annotated -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import account_ref from ..core import context as context_1 @@ -25,6 +23,13 @@ from ..core.version_envelope import AdcpVersionEnvelope +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class DiscoveryMode(StrEnum): brief = 'brief' wholesale = 'wholesale' @@ -163,3 +168,38 @@ class GetSignalsRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetSignalsRequest: + # Compiled from signals/get-signals-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (not (_c.sent(self, 'signal_spec') or _c.sent(self, 'signal_refs') or _c.sent(self, 'signal_ids')) if (_c.descend(self, 'discovery_mode', lambda discovery_mode: discovery_mode == 'wholesale') and _c.sent(self, 'discovery_mode')) else (_c.sent(self, 'signal_spec') or _c.sent(self, 'signal_refs') or _c.sent(self, 'signal_ids'))) + ): + raise PydanticCustomError( + 'allOf', + 'GetSignalsRequest violates a constraint its schema states outside its properties (signals/get-signals-request.json, root conjunct 0) over discovery_mode, signal_ids, signal_refs, signal_spec', + {'fields': 'discovery_mode, signal_ids, signal_refs, signal_spec'}, + ) + if not ( + not (_c.supplied(self, 'if_pricing_version')) + or _c.supplied(self, 'if_wholesale_feed_version') + ): + raise PydanticCustomError( + 'allOf', + 'GetSignalsRequest violates a constraint its schema states outside its properties (signals/get-signals-request.json, root conjunct 1) over if_pricing_version, if_wholesale_feed_version', + {'fields': 'if_pricing_version, if_wholesale_feed_version'}, + ) + if not ( + ((_c.descend(self, 'discovery_mode', lambda discovery_mode: discovery_mode == 'wholesale') and _c.sent(self, 'discovery_mode')) if (_c.sent(self, 'if_wholesale_feed_version') or _c.sent(self, 'if_pricing_version')) else True) + ): + raise PydanticCustomError( + 'allOf', + 'GetSignalsRequest violates a constraint its schema states outside its properties (signals/get-signals-request.json, root conjunct 2) over discovery_mode, if_pricing_version, if_wholesale_feed_version', + {'fields': 'discovery_mode, if_pricing_version, if_wholesale_feed_version'}, + ) + return self diff --git a/src/adcp/types/domains/signals/get_signals_response.py b/src/adcp/types/domains/signals/get_signals_response.py index 9b8e1ff2d..0e3c97071 100644 --- a/src/adcp/types/domains/signals/get_signals_response.py +++ b/src/adcp/types/domains/signals/get_signals_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: signals/get_signals_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -11,8 +11,7 @@ from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, StrictFloat +from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, StrictFloat, model_validator from ..core import context as context_1 from ..core import demographic_predicate as demographic_predicate_1 @@ -29,6 +28,13 @@ from ..enums import restricted_attribute, signal_catalog_type, signal_value_type +from adcp.types import _conditions as _c +from adcp.types._scalar import ScalarStr + + +from pydantic_core import PydanticCustomError + + class Range(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -473,3 +479,21 @@ class GetSignalsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> GetSignalsResponse: + # Compiled from signals/get-signals-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + ((not (_c.sent(self, 'signals')) and (_c.sent(self, 'wholesale_feed_version') and _c.sent(self, 'cache_scope'))) if _c.sent(self, 'unchanged') else (_c.sent(self, 'errors') if (_c.descend(self, 'status', lambda status: status == 'failed') and _c.supplied(self, 'status')) else (_c.sent(self, 'signals') and _c.sent(self, 'cache_scope')))) + ): + raise PydanticCustomError( + 'allOf', + 'GetSignalsResponse violates a constraint its schema states outside its properties (signals/get-signals-response.json, root conjunct 0) over cache_scope, errors, signals, status, unchanged, wholesale_feed_version', + {'fields': 'cache_scope, errors, signals, status, unchanged, wholesale_feed_version'}, + ) + return self diff --git a/src/adcp/types/domains/sponsored_intelligence/__init__.py b/src/adcp/types/domains/sponsored_intelligence/__init__.py index 4e8d57292..f4bedd499 100644 --- a/src/adcp/types/domains/sponsored_intelligence/__init__.py +++ b/src/adcp/types/domains/sponsored_intelligence/__init__.py @@ -9,85 +9,198 @@ it from its own schema's module, ``adcp.types.domains.sponsored_intelligence.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.sponsored_intelligence.si_capabilities import ( - A2ui, - Avatar, - Commerce, - Components, - Modalities, - SiCapabilities, - StandardEnum, - Video, - Voice, -) -from adcp.types.domains.sponsored_intelligence.si_context_use import SiContextUse -from adcp.types.domains.sponsored_intelligence.si_get_offering_request import SiGetOfferingRequest -from adcp.types.domains.sponsored_intelligence.si_get_offering_response import ( - MatchingProduct, - Offering, - SiGetOfferingResponse, -) -from adcp.types.domains.sponsored_intelligence.si_identity import ( - ConsentScopeEnum, - PrivacyPolicyAcknowledged, - ShippingAddress, - SiIdentity, - User, -) -from adcp.types.domains.sponsored_intelligence.si_initiate_session_request import ( - SiInitiateSessionRequest, -) -from adcp.types.domains.sponsored_intelligence.si_initiate_session_response import ( - SiInitiateSessionResponse, -) -from adcp.types.domains.sponsored_intelligence.si_send_message_request import ( - ActionResponse, - SiSendMessageRequest, -) -from adcp.types.domains.sponsored_intelligence.si_send_message_response import ( - ContextForCheckout, - Handoff, - Intent, - Price, - SiSendMessageResponse, -) -from adcp.types.domains.sponsored_intelligence.si_sponsored_context import ( - Account, - DeclaredBy, - DisclosureObligation, - Jurisdiction, - PayingPrincipal, - Proximity, - Role, - SiSponsoredContext, - Timing, -) -from adcp.types.domains.sponsored_intelligence.si_sponsored_context_receipt import ( - DisclosureCommitment, - HostReceipt, - SiSponsoredContextReceipt, - Status, - Status46, -) -from adcp.types.domains.sponsored_intelligence.si_terminate_session_request import ( - Reason, - SiTerminateSessionRequest, - TerminationContext, - TransactionIntent, -) -from adcp.types.domains.sponsored_intelligence.si_terminate_session_response import ( - AcpHandoff, - FollowUp, - SiTerminateSessionResponse, -) -from adcp.types.domains.sponsored_intelligence.si_ui_element import SiUiElement +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.sponsored_intelligence.si_capabilities import ( + A2ui, + Avatar, + Commerce, + Components, + Modalities, + SiCapabilities, + StandardEnum, + Video, + Voice, + ) + from adcp.types.domains.sponsored_intelligence.si_context_use import SiContextUse + from adcp.types.domains.sponsored_intelligence.si_get_offering_request import ( + SiGetOfferingRequest, + ) + from adcp.types.domains.sponsored_intelligence.si_get_offering_response import ( + MatchingProduct, + Offering, + SiGetOfferingResponse, + ) + from adcp.types.domains.sponsored_intelligence.si_identity import ( + ConsentScopeEnum, + PrivacyPolicyAcknowledged, + ShippingAddress, + SiIdentity, + User, + ) + from adcp.types.domains.sponsored_intelligence.si_initiate_session_request import ( + SiInitiateSessionRequest, + ) + from adcp.types.domains.sponsored_intelligence.si_initiate_session_response import ( + SiInitiateSessionResponse, + ) + from adcp.types.domains.sponsored_intelligence.si_send_message_request import ( + ActionResponse, + SiSendMessageRequest, + ) + from adcp.types.domains.sponsored_intelligence.si_send_message_response import ( + ContextForCheckout, + Handoff, + Intent, + Price, + SiSendMessageResponse, + ) + from adcp.types.domains.sponsored_intelligence.si_sponsored_context import ( + Account, + DeclaredBy, + DisclosureObligation, + Jurisdiction, + PayingPrincipal, + Proximity, + Role, + SiSponsoredContext, + Timing, + ) + from adcp.types.domains.sponsored_intelligence.si_sponsored_context_receipt import ( + DisclosureCommitment, + HostReceipt, + SiSponsoredContextReceipt, + Status, + Status46, + ) + from adcp.types.domains.sponsored_intelligence.si_terminate_session_request import ( + Reason, + SiTerminateSessionRequest, + TerminationContext, + TransactionIntent, + ) + from adcp.types.domains.sponsored_intelligence.si_terminate_session_response import ( + AcpHandoff, + FollowUp, + SiTerminateSessionResponse, + ) + from adcp.types.domains.sponsored_intelligence.si_ui_element import SiUiElement + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "sponsored_intelligence.si_capabilities": ( + "A2ui", + "Avatar", + "Commerce", + "Components", + "Modalities", + "SiCapabilities", + "StandardEnum", + "Video", + "Voice", + ), + "sponsored_intelligence.si_context_use": ("SiContextUse",), + "sponsored_intelligence.si_get_offering_request": ("SiGetOfferingRequest",), + "sponsored_intelligence.si_get_offering_response": ( + "MatchingProduct", + "Offering", + "SiGetOfferingResponse", + ), + "sponsored_intelligence.si_identity": ( + "ConsentScopeEnum", + "PrivacyPolicyAcknowledged", + "ShippingAddress", + "SiIdentity", + "User", + ), + "sponsored_intelligence.si_initiate_session_request": ("SiInitiateSessionRequest",), + "sponsored_intelligence.si_initiate_session_response": ("SiInitiateSessionResponse",), + "sponsored_intelligence.si_send_message_request": ( + "ActionResponse", + "SiSendMessageRequest", + ), + "sponsored_intelligence.si_send_message_response": ( + "ContextForCheckout", + "Handoff", + "Intent", + "Price", + "SiSendMessageResponse", + ), + "sponsored_intelligence.si_sponsored_context": ( + "Account", + "DeclaredBy", + "DisclosureObligation", + "Jurisdiction", + "PayingPrincipal", + "Proximity", + "Role", + "SiSponsoredContext", + "Timing", + ), + "sponsored_intelligence.si_sponsored_context_receipt": ( + "DisclosureCommitment", + "HostReceipt", + "SiSponsoredContextReceipt", + "Status", + "Status46", + ), + "sponsored_intelligence.si_terminate_session_request": ( + "Reason", + "SiTerminateSessionRequest", + "TerminationContext", + "TransactionIntent", + ), + "sponsored_intelligence.si_terminate_session_response": ( + "AcpHandoff", + "FollowUp", + "SiTerminateSessionResponse", + ), + "sponsored_intelligence.si_ui_element": ("SiUiElement",), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/sponsored_intelligence/si_send_message_request.py b/src/adcp/types/domains/sponsored_intelligence/si_send_message_request.py index 2dc9b8eba..23144d9a9 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_send_message_request.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_send_message_request.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_send_message_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest -from typing import Annotated, Any +from typing import Annotated, Any, ClassVar from adcp.types.base import AdCPBaseModel from pydantic import ConfigDict, Field, model_validator @@ -17,6 +17,9 @@ from . import si_sponsored_context_receipt +from adcp.types import _conditions as _c + + from pydantic_core import PydanticCustomError @@ -57,19 +60,29 @@ class SiSendMessageRequest(AdcpRequest, AdcpVersionEnvelope): ] = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None + _COMBINATOR_FIELDS: ClassVar[frozenset[str]] = frozenset({'action_response', 'message'}) @model_validator(mode='after') def _require_schema_required_group(self) -> SiSendMessageRequest: - # ``required`` asks whether the caller supplied the field, which is what - # model_fields_set answers. An explicit null is a supplied value — on a - # mutation input it is the command to clear — and a default the caller - # never sent is not. - for group in (('message',), ('action_response',),): - if all(name in self.model_fields_set for name in group): - return self - # The refusal names the root combinator the groups came from, so a - # consumer reads the keyword off ``e.errors()[0]["type"]``. - raise PydanticCustomError( - 'anyOf', - 'SiSendMessageRequest requires at least one of these field groups: message | action_response', - ) + # Compiled from sponsored-intelligence/si-send-message-request.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + # message + ( + _c.sent(self, 'message') + ) + # action_response + or ( + _c.sent(self, 'action_response') + ) + ): + raise PydanticCustomError( + 'anyOf', + 'SiSendMessageRequest requires at least one of these field groups: message | action_response', + ) + return self diff --git a/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py b/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py index 4246a0e5f..563505023 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_sponsored_context_receipt.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,12 +8,18 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..core import ext as ext_1 from . import si_context_use, si_sponsored_context +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Status(StrEnum): accepted = 'accepted' rejected = 'rejected' @@ -99,3 +105,45 @@ class SiSponsoredContextReceipt(AdCPBaseModel): ), ] ext: ext_1.ExtensionObject | None = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> SiSponsoredContextReceipt: + # Compiled from sponsored-intelligence/si-sponsored-context-receipt.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'host_receipt', lambda host_receipt: _c.descend(host_receipt, 'accepted_context_use', lambda accepted_context_use: accepted_context_use == 'presentation_only')) if (_c.descend(self, 'host_receipt', lambda host_receipt: _c.descend(host_receipt, 'status', lambda status: status == 'accepted') and _c.sent(host_receipt, 'status')) and _c.descend(self, 'sponsored_context', lambda sponsored_context: _c.descend(sponsored_context, 'context_use', lambda context_use: context_use == 'presentation_only') and _c.sent(sponsored_context, 'context_use'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiSponsoredContextReceipt violates a constraint its schema states outside its properties (sponsored-intelligence/si-sponsored-context-receipt.json, root conjunct 0) over accepted_context_use, context_use, host_receipt, sponsored_context, status', + {'fields': 'accepted_context_use, context_use, host_receipt, sponsored_context, status'}, + ) + if not ( + (_c.descend(self, 'host_receipt', lambda host_receipt: _c.descend(host_receipt, 'accepted_context_use', lambda accepted_context_use: accepted_context_use == 'comparison_set')) if (_c.descend(self, 'host_receipt', lambda host_receipt: _c.descend(host_receipt, 'status', lambda status: status == 'accepted') and _c.sent(host_receipt, 'status')) and _c.descend(self, 'sponsored_context', lambda sponsored_context: _c.descend(sponsored_context, 'context_use', lambda context_use: context_use == 'comparison_set') and _c.sent(sponsored_context, 'context_use'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiSponsoredContextReceipt violates a constraint its schema states outside its properties (sponsored-intelligence/si-sponsored-context-receipt.json, root conjunct 1) over accepted_context_use, context_use, host_receipt, sponsored_context, status', + {'fields': 'accepted_context_use, context_use, host_receipt, sponsored_context, status'}, + ) + if not ( + (_c.descend(self, 'host_receipt', lambda host_receipt: _c.descend(host_receipt, 'accepted_context_use', lambda accepted_context_use: accepted_context_use == 'reasoning_context')) if (_c.descend(self, 'host_receipt', lambda host_receipt: _c.descend(host_receipt, 'status', lambda status: status == 'accepted') and _c.sent(host_receipt, 'status')) and _c.descend(self, 'sponsored_context', lambda sponsored_context: _c.descend(sponsored_context, 'context_use', lambda context_use: context_use == 'reasoning_context') and _c.sent(sponsored_context, 'context_use'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiSponsoredContextReceipt violates a constraint its schema states outside its properties (sponsored-intelligence/si-sponsored-context-receipt.json, root conjunct 2) over accepted_context_use, context_use, host_receipt, sponsored_context, status', + {'fields': 'accepted_context_use, context_use, host_receipt, sponsored_context, status'}, + ) + if not ( + (_c.descend(self, 'host_receipt', lambda host_receipt: _c.descend(host_receipt, 'disclosure_commitment', lambda disclosure_commitment: _c.descend(disclosure_commitment, 'status', lambda status: status == 'accepted'))) if (_c.descend(self, 'host_receipt', lambda host_receipt: _c.descend(host_receipt, 'status', lambda status: status == 'accepted') and _c.sent(host_receipt, 'status')) and _c.descend(self, 'sponsored_context', lambda sponsored_context: _c.descend(sponsored_context, 'disclosure_obligation', lambda disclosure_obligation: _c.descend(disclosure_obligation, 'required', lambda required: required == True) and _c.supplied(disclosure_obligation, 'required')) and _c.sent(sponsored_context, 'disclosure_obligation'))) else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiSponsoredContextReceipt violates a constraint its schema states outside its properties (sponsored-intelligence/si-sponsored-context-receipt.json, root conjunct 3) over disclosure_commitment, disclosure_obligation, host_receipt, required, sponsored_context, status', + {'fields': 'disclosure_commitment, disclosure_obligation, host_receipt, required, sponsored_context, status'}, + ) + return self diff --git a/src/adcp/types/domains/sponsored_intelligence/si_ui_element.py b/src/adcp/types/domains/sponsored_intelligence/si_ui_element.py index 92556de6d..834591a7a 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_ui_element.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_ui_element.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_ui_element.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -8,7 +8,13 @@ from typing import Annotated, Any from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator + + +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError class Type(StrEnum): @@ -28,3 +34,77 @@ class SiUiElement(AdCPBaseModel): ) type: Annotated[Type, Field(description='Component type')] data: Annotated[dict[str, Any] | None, Field(description='Component-specific data')] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> SiUiElement: + # Compiled from sponsored-intelligence/si-ui-element.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + (_c.descend(self, 'data', lambda data: _c.descend(data, 'message', lambda message: _c.json_type(message, 'string')) and _c.supplied(data, 'message')) if _c.descend(self, 'type', lambda type: type == 'text') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiUiElement violates a constraint its schema states outside its properties (sponsored-intelligence/si-ui-element.json, root conjunct 0) over data, message, type', + {'fields': 'data, message, type'}, + ) + if not ( + (_c.descend(self, 'data', lambda data: (_c.descend(data, 'label', lambda label: _c.json_type(label, 'string')) and _c.descend(data, 'preview', lambda preview: _c.json_type(preview, 'boolean')) and _c.descend(data, 'url', lambda url: _c.json_type(url, 'string'))) and (_c.supplied(data, 'url') and _c.supplied(data, 'label'))) if _c.descend(self, 'type', lambda type: type == 'link') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiUiElement violates a constraint its schema states outside its properties (sponsored-intelligence/si-ui-element.json, root conjunct 1) over data, label, preview, type, url', + {'fields': 'data, label, preview, type, url'}, + ) + if not ( + (_c.descend(self, 'data', lambda data: (_c.descend(data, 'alt', lambda alt: _c.json_type(alt, 'string')) and _c.descend(data, 'caption', lambda caption: _c.json_type(caption, 'string')) and _c.descend(data, 'url', lambda url: _c.json_type(url, 'string'))) and (_c.supplied(data, 'url') and _c.supplied(data, 'alt'))) if _c.descend(self, 'type', lambda type: type == 'image') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiUiElement violates a constraint its schema states outside its properties (sponsored-intelligence/si-ui-element.json, root conjunct 2) over alt, caption, data, type, url', + {'fields': 'alt, caption, data, type, url'}, + ) + if not ( + (_c.descend(self, 'data', lambda data: (_c.descend(data, 'badge', lambda badge: _c.json_type(badge, 'string')) and _c.descend(data, 'cta', lambda cta: (_c.descend(cta, 'action', lambda action: _c.json_type(action, 'string')) and _c.descend(cta, 'label', lambda label: _c.json_type(label, 'string'))) and _c.json_type(cta, 'object')) and _c.descend(data, 'description', lambda description: _c.json_type(description, 'string')) and _c.descend(data, 'image_url', lambda image_url: _c.json_type(image_url, 'string')) and _c.descend(data, 'price', lambda price: _c.json_type(price, 'string')) and _c.descend(data, 'subtitle', lambda subtitle: _c.json_type(subtitle, 'string')) and _c.descend(data, 'title', lambda title: _c.json_type(title, 'string'))) and (_c.supplied(data, 'title') and _c.supplied(data, 'price'))) if _c.descend(self, 'type', lambda type: type == 'product_card') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiUiElement violates a constraint its schema states outside its properties (sponsored-intelligence/si-ui-element.json, root conjunct 3) over action, badge, cta, data, description, image_url, label, price, subtitle, title, type', + {'fields': 'action, badge, cta, data, description, image_url, label, price, subtitle, title, type'}, + ) + if not ( + (_c.descend(self, 'data', lambda data: (_c.descend(data, 'items', lambda items: _c.every(items, lambda item: _c.json_type(item, 'object')) and _c.json_type(items, 'array')) and _c.descend(data, 'title', lambda title: _c.json_type(title, 'string'))) and _c.supplied(data, 'items')) if _c.descend(self, 'type', lambda type: type == 'carousel') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiUiElement violates a constraint its schema states outside its properties (sponsored-intelligence/si-ui-element.json, root conjunct 4) over data, items, title, type', + {'fields': 'data, items, title, type'}, + ) + if not ( + (_c.descend(self, 'data', lambda data: (_c.descend(data, 'action', lambda action: _c.json_type(action, 'string')) and _c.descend(data, 'label', lambda label: _c.json_type(label, 'string')) and _c.descend(data, 'payload', lambda payload: _c.json_type(payload, 'object'))) and (_c.supplied(data, 'label') and _c.supplied(data, 'action'))) if _c.descend(self, 'type', lambda type: type == 'action_button') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiUiElement violates a constraint its schema states outside its properties (sponsored-intelligence/si-ui-element.json, root conjunct 5) over action, data, label, payload, type', + {'fields': 'action, data, label, payload, type'}, + ) + if not ( + (_c.descend(self, 'apps', lambda apps: _c.others(apps, [], lambda item: (_c.descend(item, 'app_id', lambda app_id: _c.json_type(app_id, 'string')) and _c.descend(item, 'deep_link', lambda deep_link: _c.json_type(deep_link, 'string')) and _c.descend(item, 'url', lambda url: _c.json_type(url, 'string'))) and _c.json_type(item, 'object')) and _c.json_type(apps, 'object')) if _c.descend(self, 'type', lambda type: type == 'app_handoff') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiUiElement violates a constraint its schema states outside its properties (sponsored-intelligence/si-ui-element.json, root conjunct 6) over app_id, apps, deep_link, type, url', + {'fields': 'app_id, apps, deep_link, type, url'}, + ) + if not ( + (_c.descend(self, 'data', lambda data: _c.descend(data, 'actions', lambda actions: _c.every(actions, lambda item: (_c.descend(item, 'highlighted', lambda highlighted: _c.json_type(highlighted, 'boolean')) and _c.descend(item, 'label', lambda label: _c.json_type(label, 'string')) and _c.descend(item, 'type', lambda type: type in ['mcp', 'a2a'] and _c.json_type(type, 'string')) and _c.descend(item, 'url', lambda url: _c.json_type(url, 'string'))) and (_c.supplied(item, 'type') and _c.supplied(item, 'label')) and _c.json_type(item, 'object')) and _c.json_type(actions, 'array')) and _c.supplied(data, 'actions')) if _c.descend(self, 'type', lambda type: type == 'integration_actions') else True) + ): + raise PydanticCustomError( + 'allOf', + 'SiUiElement violates a constraint its schema states outside its properties (sponsored-intelligence/si-ui-element.json, root conjunct 7) over actions, data, highlighted, label, type, url', + {'fields': 'actions, data, highlighted, label, type, url'}, + ) + return self diff --git a/src/adcp/types/domains/trusted_match/__init__.py b/src/adcp/types/domains/trusted_match/__init__.py index 86d06402b..886dbc32c 100644 --- a/src/adcp/types/domains/trusted_match/__init__.py +++ b/src/adcp/types/domains/trusted_match/__init__.py @@ -9,63 +9,156 @@ it from its own schema's module, ``adcp.types.domains.trusted_match.<schema>``. Nothing here is renamed. +This facade is LAZY (PEP 562). It binds no name at import time, because an +eager one makes every leaf of this domain depend on every other leaf: a +submodule import runs this ``__init__``, so one leaf's dependencies become +the whole domain's. That turned a benign submodule-level cycle between +``core`` and ``formats.canonical`` -- two domains whose schemas reference +each other, which Python handles -- into a package-level one that made all +16 modules under ``formats/canonical/`` unimportable on their own. + Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-04 18:45:11 UTC +Generation date: 2026-10-16 22:14:49 UTC """ # ruff: noqa: E501, I001 from __future__ import annotations -from adcp.types.domains.trusted_match.available_package import AvailablePackage -from adcp.types.domains.trusted_match.context_match_request import ( - ArtifactRef, - ContextMatchRequest, - ContextSignals, - Geo, - Keyword, - Metro, - Sentiment, - Type, -) -from adcp.types.domains.trusted_match.context_match_response import ( - ContextMatchResponseRouterPublisher, - SignalsByProvider, -) -from adcp.types.domains.trusted_match.error import Code, TmpError -from adcp.types.domains.trusted_match.identity_match_request import ( - Attestation, - Consent, - Identity, - IdentityMatchRequest, - SealedCredential, - VerificationLevel, -) -from adcp.types.domains.trusted_match.identity_match_response import ( - IdentityMatchResponse, - IdentityMatchResponseRouterPublisher, - TmpxProviders, -) -from adcp.types.domains.trusted_match.offer import Offer -from adcp.types.domains.trusted_match.offer_price import Model, OfferPrice -from adcp.types.domains.trusted_match.provider_context_match_response import ( - ContextMatchResponseProviderRouter, -) -from adcp.types.domains.trusted_match.provider_identity_match_response import ( - IdentityMatchResponseProviderRouter, -) -from adcp.types.domains.trusted_match.provider_registration import ( - Country, - Status, - TmpProviderRegistration, - TmpProviderRegistration1, - TmpProviderRegistration2, - TmpxSlot, -) -from adcp.types.domains.trusted_match.publisher_targeting_kv_config import ( - PublisherTargetingKvMapping, -) -from adcp.types.domains.trusted_match.publisher_tmpx_config import PublisherTmpxMacroMapping -from adcp.types.domains.trusted_match.tmpx_chunk import TmpxChunk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # The whole importable surface, so type checkers and IDEs see it. Runtime + # resolution is ``__getattr__`` below; mypy never sees that, so a typo'd + # import is still an error rather than ``object``. + from adcp.types.domains.trusted_match.available_package import AvailablePackage + from adcp.types.domains.trusted_match.context_match_request import ( + ArtifactRef, + ContextMatchRequest, + ContextSignals, + Geo, + Keyword, + Metro, + Sentiment, + Type, + ) + from adcp.types.domains.trusted_match.context_match_response import ( + ContextMatchResponseRouterPublisher, + SignalsByProvider, + ) + from adcp.types.domains.trusted_match.error import Code, TmpError + from adcp.types.domains.trusted_match.identity_match_request import ( + Attestation, + Consent, + Identity, + IdentityMatchRequest, + SealedCredential, + VerificationLevel, + ) + from adcp.types.domains.trusted_match.identity_match_response import ( + IdentityMatchResponse, + IdentityMatchResponseRouterPublisher, + TmpxProviders, + ) + from adcp.types.domains.trusted_match.offer import Offer + from adcp.types.domains.trusted_match.offer_price import Model, OfferPrice + from adcp.types.domains.trusted_match.provider_context_match_response import ( + ContextMatchResponseProviderRouter, + ) + from adcp.types.domains.trusted_match.provider_identity_match_response import ( + IdentityMatchResponseProviderRouter, + ) + from adcp.types.domains.trusted_match.provider_registration import ( + Country, + Status, + TmpProviderRegistration, + TmpProviderRegistration1, + TmpProviderRegistration2, + TmpxSlot, + ) + from adcp.types.domains.trusted_match.publisher_targeting_kv_config import ( + PublisherTargetingKvMapping, + ) + from adcp.types.domains.trusted_match.publisher_tmpx_config import PublisherTmpxMacroMapping + from adcp.types.domains.trusted_match.tmpx_chunk import TmpxChunk + +#: Which schema module declares each re-exported name. +#: +#: Resolution is PER NAME, a deliberate divergence from +#: ``adcp.types.__init__``, which routes every name through one ``_eager`` +#: module. Importing a whole domain graph on first access can re-enter the +#: cycle this laziness exists to break, when that first access happens while +#: a canonical format module is still initializing. Importing only the one +#: declaring module cannot. +_DECLARED_BY = { + "trusted_match.available_package": ("AvailablePackage",), + "trusted_match.context_match_request": ( + "ArtifactRef", + "ContextMatchRequest", + "ContextSignals", + "Geo", + "Keyword", + "Metro", + "Sentiment", + "Type", + ), + "trusted_match.context_match_response": ( + "ContextMatchResponseRouterPublisher", + "SignalsByProvider", + ), + "trusted_match.error": ( + "Code", + "TmpError", + ), + "trusted_match.identity_match_request": ( + "Attestation", + "Consent", + "Identity", + "IdentityMatchRequest", + "SealedCredential", + "VerificationLevel", + ), + "trusted_match.identity_match_response": ( + "IdentityMatchResponse", + "IdentityMatchResponseRouterPublisher", + "TmpxProviders", + ), + "trusted_match.offer": ("Offer",), + "trusted_match.offer_price": ( + "Model", + "OfferPrice", + ), + "trusted_match.provider_context_match_response": ("ContextMatchResponseProviderRouter",), + "trusted_match.provider_identity_match_response": ("IdentityMatchResponseProviderRouter",), + "trusted_match.provider_registration": ( + "Country", + "Status", + "TmpProviderRegistration", + "TmpProviderRegistration1", + "TmpProviderRegistration2", + "TmpxSlot", + ), + "trusted_match.publisher_targeting_kv_config": ("PublisherTargetingKvMapping",), + "trusted_match.publisher_tmpx_config": ("PublisherTmpxMacroMapping",), + "trusted_match.tmpx_chunk": ("TmpxChunk",), +} + +if not TYPE_CHECKING: + import importlib + + _DECLARING_MODULE = {name: module for module, names in _DECLARED_BY.items() for name in names} + + def __getattr__(name: str) -> object: + """Resolve a re-exported name by importing the ONE module declaring it.""" + module = _DECLARING_MODULE.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + value = getattr(importlib.import_module(f"adcp.types.domains.{module}"), name) + globals()[name] = value # cache: __getattr__ fires once per name + return value + + def __dir__() -> list[str]: + return sorted(set(__all__) | set(globals())) + # Explicit exports __all__ = [ diff --git a/src/adcp/types/domains/trusted_match/context_match_response.py b/src/adcp/types/domains/trusted_match/context_match_response.py index f9e878915..16301dc81 100644 --- a/src/adcp/types/domains/trusted_match/context_match_response.py +++ b/src/adcp/types/domains/trusted_match/context_match_response.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: trusted_match/context_match_response.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, RootModel, StringConstraints +from pydantic import ConfigDict, Field, RootModel, StringConstraints, model_validator from ..core.protocol_envelope import ProtocolEnvelope from ..core.version_envelope import AdcpVersionEnvelope from . import offer +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class Signals(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -64,8 +70,12 @@ class ContextMatchResponseRouterPublisher(AdcpVersionEnvelope, ProtocolEnvelope) request_id: Annotated[ str, Field(description='Echoed request identifier from the context match request.') ] + # A response carries the tolerant read-back node, not the strict input class: a seller may + # send an unknown field, or a manifest instance of a sibling class, and neither may become + # acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in + # scripts/post_generate_fixes.py. offers: Annotated[ - list[offer.Offer], + list[offer._ReadbackOffer], Field( description='Offers collected across the provider fan-out, one per activated package. An empty array means no packages matched. For simple activation, each offer has just package_id. For richer responses, offers include brand, price, summary, and creative manifest.' ), @@ -84,3 +94,21 @@ class ContextMatchResponseRouterPublisher(AdcpVersionEnvelope, ProtocolEnvelope) min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ContextMatchResponseRouterPublisher: + # Compiled from trusted-match/context-match-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not ((_c.descend(self, 'signals', lambda signals: _c.supplied(signals, 'targeting_kvs')) and _c.sent(self, 'signals')) or _c.supplied(self, 'context') or _c.supplied(self, 'ext')) + ): + raise PydanticCustomError( + 'allOf', + 'ContextMatchResponseRouterPublisher violates a constraint its schema states outside its properties (trusted-match/context-match-response.json, root conjunct 0) over context, ext, signals, targeting_kvs', + {'fields': 'context, ext, signals, targeting_kvs'}, + ) + return self diff --git a/src/adcp/types/domains/trusted_match/identity_match_response.py b/src/adcp/types/domains/trusted_match/identity_match_response.py index bd5eb5e1a..0149d5ff9 100644 --- a/src/adcp/types/domains/trusted_match/identity_match_response.py +++ b/src/adcp/types/domains/trusted_match/identity_match_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: trusted_match/identity_match_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations @@ -18,6 +18,12 @@ from . import tmpx_chunk +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class TmpxMacro(AdCPBaseModel): """Deprecated 3.1.8 TMPX macro/value compatibility model.""" @@ -84,6 +90,24 @@ class IdentityMatchResponseRouterPublisher(AdcpVersionEnvelope, ProtocolEnvelope description="Router-populated: ordered TMPX chunk/value pairs grouped by the originating identity provider's `provider_id`. Each entry's `chunks[]` is a copy of the provider's emitted `tmpx_chunks` list, in the same order. Each chunk carries a provider-local `slot_id` (from the provider's registered `tmpx_slots`) and an opaque URL-safe `value`; the publisher's deployment configuration (see publisher-tmpx-config.json) resolves each `(provider_id, slot_id)` pair to the ad-server macro name, targeting key, VAST substitution, or play-log field for that surface. The protocol carries values and attribution only. Required by router conformance when any identity provider emitted TMPX in this request; collapsing per-provider tokens into a single string loses attribution and breaks per-provider impression accounting. Map keys MUST match the provider_id charset registered in provider-registration.json (enforced by `propertyNames`). Publishers MUST NOT parse, decode, or transform any chunk's `value` — each is an opaque URL-safe wire string substituted verbatim into the mapped destination." ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> IdentityMatchResponseRouterPublisher: + # Compiled from trusted-match/identity-match-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'tmpx_chunks') or _c.supplied(self, 'tmpx_values') or _c.supplied(self, 'tmpx_macros') or _c.supplied(self, 'context') or _c.supplied(self, 'ext')) + ): + raise PydanticCustomError( + 'allOf', + 'IdentityMatchResponseRouterPublisher violates a constraint its schema states outside its properties (trusted-match/identity-match-response.json, root conjunct 0) over context, ext, tmpx_chunks, tmpx_macros, tmpx_values', + {'fields': 'context, ext, tmpx_chunks, tmpx_macros, tmpx_values'}, + ) + return self @model_validator(mode='after') def _validate_tmpx_provider_ids(self) -> IdentityMatchResponseRouterPublisher: diff --git a/src/adcp/types/domains/trusted_match/offer.py b/src/adcp/types/domains/trusted_match/offer.py index 0cd6257cb..9d8f27e86 100644 --- a/src/adcp/types/domains/trusted_match/offer.py +++ b/src/adcp/types/domains/trusted_match/offer.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: trusted_match/offer.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core import brand_ref from ..core import creative_manifest as creative_manifest_1 @@ -15,6 +15,15 @@ from . import offer_price +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + +from adcp.types._readback import readback_clone + + class Offer(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -56,3 +65,28 @@ class Offer(AdCPBaseModel): description="Optional free-form string enhancements for dynamic creative rendering, such as sponsor labels and promotion codes. The map has no protocol-level key registry or required keys: meanings are package-local, receivers MUST ignore unknown keys, and a missing key MUST NOT make an otherwise renderable offer fail. Required renderable content belongs in creative_manifest assets, so creative_data may complement but never replace a manifest's required assets. This is not ad-server macro substitution or attribution tracking; tracker URLs belong in creative_manifest assets and per-user exposure tracking uses TMPX." ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> Offer: + # Compiled from trusted-match/offer.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'macros')) + ): + raise PydanticCustomError( + 'allOf', + 'Offer violates a constraint its schema states outside its properties (trusted-match/offer.json, root conjunct 0) over macros', + {'fields': 'macros'}, + ) + return self + + +_ReadbackOffer = readback_clone( + "_ReadbackOffer", + Offer, + {"creative_manifest": creative_manifest_1._ReadbackCreativeManifest | None}, +) diff --git a/src/adcp/types/domains/trusted_match/provider_context_match_response.py b/src/adcp/types/domains/trusted_match/provider_context_match_response.py index b120d6b25..675fbb11d 100644 --- a/src/adcp/types/domains/trusted_match/provider_context_match_response.py +++ b/src/adcp/types/domains/trusted_match/provider_context_match_response.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: trusted_match/provider_context_match_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import ConfigDict, Field, RootModel +from pydantic import ConfigDict, Field, RootModel, model_validator from ..core.protocol_envelope import ProtocolEnvelope from ..core.version_envelope import AdcpVersionEnvelope from . import offer +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class TargetingKv(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -59,8 +65,12 @@ class ContextMatchResponseProviderRouter(AdcpVersionEnvelope, ProtocolEnvelope): request_id: Annotated[ str, Field(description='Echoed request identifier from the context match request.') ] + # A response carries the tolerant read-back node, not the strict input class: a seller may + # send an unknown field, or a manifest instance of a sibling class, and neither may become + # acceptable on the INPUT model (#1241). See adcp.types._readback and READBACK_CLONES in + # scripts/post_generate_fixes.py. offers: Annotated[ - list[offer.Offer], + list[offer._ReadbackOffer], Field( description='Offers from this provider, one per activated package. An empty array means no packages matched. For simple activation, each offer has just package_id. For richer responses, offers include brand, price, summary, and creative manifest.' ), @@ -79,3 +89,21 @@ class ContextMatchResponseProviderRouter(AdcpVersionEnvelope, ProtocolEnvelope): description="Provider-origin response-level signals. Targeting pairs use the provider's local key vocabulary; the router preserves and attributes them rather than passing them through as flattened publisher targeting." ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> ContextMatchResponseProviderRouter: + # Compiled from trusted-match/provider-context-match-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'signals_by_provider') or _c.supplied(self, 'context') or _c.supplied(self, 'ext')) + ): + raise PydanticCustomError( + 'allOf', + 'ContextMatchResponseProviderRouter violates a constraint its schema states outside its properties (trusted-match/provider-context-match-response.json, root conjunct 0) over context, ext, signals_by_provider', + {'fields': 'context, ext, signals_by_provider'}, + ) + return self diff --git a/src/adcp/types/domains/trusted_match/provider_identity_match_response.py b/src/adcp/types/domains/trusted_match/provider_identity_match_response.py index 91d88bb28..30b3fe85a 100644 --- a/src/adcp/types/domains/trusted_match/provider_identity_match_response.py +++ b/src/adcp/types/domains/trusted_match/provider_identity_match_response.py @@ -1,19 +1,25 @@ # generated by datamodel-codegen: # filename: trusted_match/provider_identity_match_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-16T22:14:31+00:00 from __future__ import annotations from typing import Annotated, Literal from adcp.types.base import SchemaInt -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from ..core.protocol_envelope import ProtocolEnvelope from ..core.version_envelope import AdcpVersionEnvelope from . import tmpx_chunk +from adcp.types import _conditions as _c + + +from pydantic_core import PydanticCustomError + + class IdentityMatchResponseProviderRouter(AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict( extra='allow', @@ -49,3 +55,21 @@ class IdentityMatchResponseProviderRouter(AdcpVersionEnvelope, ProtocolEnvelope) min_length=1, ), ] = None + @model_validator(mode='after') + def _require_schema_required_group(self) -> IdentityMatchResponseProviderRouter: + # Compiled from trusted-match/provider-identity-match-response.json: everything the root states beyond + # its merged properties, which datamodel-code-generator drops. + # + # Presence is membership in model_fields_set, which is what + # ``required`` asks. An explicit null is a supplied value -- on a + # mutation input it is the command to clear -- and a default the + # caller never sent is not. + if not ( + not (_c.supplied(self, 'tmpx_providers') or _c.supplied(self, 'tmpx') or _c.supplied(self, 'tmpx_values') or _c.supplied(self, 'tmpx_macros') or _c.supplied(self, 'context') or _c.supplied(self, 'ext')) + ): + raise PydanticCustomError( + 'allOf', + 'IdentityMatchResponseProviderRouter violates a constraint its schema states outside its properties (trusted-match/provider-identity-match-response.json, root conjunct 0) over context, ext, tmpx, tmpx_macros, tmpx_providers, tmpx_values', + {'fields': 'context, ext, tmpx, tmpx_macros, tmpx_providers, tmpx_values'}, + ) + return self diff --git a/src/adcp/types/error_narrowing.py b/src/adcp/types/error_narrowing.py index c7cda8eb0..a1826c7d5 100644 --- a/src/adcp/types/error_narrowing.py +++ b/src/adcp/types/error_narrowing.py @@ -26,32 +26,86 @@ from __future__ import annotations +import re +from collections.abc import Iterable from typing import Any +#: A pydantic CORE-SCHEMA label, which is the other way pydantic spells a union +#: member in ``loc``. A member that is a plain model is spelled by its class +#: name; a member that is wrapped or composed is spelled by its core schema -- +#: ``tagged-union[ImageAsset,VideoAsset]``, +#: ``function-after[_require_schema_required_group(), WebhookChallengeResponseToken]``, +#: ``nullable[...]``, ``definitions[...]``. +#: +#: Matched structurally rather than by a list of prefixes: every such label is +#: a lowercase-hyphenated schema kind followed by a bracketed body, and no field +#: name in this protocol's schemas has that shape. +_CORE_SCHEMA_LABEL = re.compile(r"^[a-z][a-z0-9-]*\[.*\]$") + -# Heuristic: a "variant" location segment is a class name. -# Pydantic emits ``("assets", "hero", "ImageAsset", "width")`` for +# Heuristic: a "variant" location segment names a union MEMBER, not a document +# key. Pydantic emits ``("assets", "hero", "ImageAsset", "width")`` for # union-validation errors. The variant name is the # second-to-last position when the error is on a field, OR the # last position when the error is at the variant itself # (e.g. ``union_tag_not_found``). def _looks_like_variant_name(segment: Any) -> bool: - """Heuristic: a Python class name (CamelCase, starts with capital). - - Used to detect variant segments in ``ValidationError.errors()[i].loc``. - Pydantic interleaves variant class names into the loc tuple for - discriminated-union failures; we strip those segments to identify - "this error belongs to variant X." + """Whether this ``loc`` segment names a union member rather than a key. + + Two spellings, because pydantic has two. A member that is a plain model is + its class name: CamelCase, no underscore. A member that is wrapped or + composed is a core-schema expression. + + Only the first was recognised, and the module's own docstring already noted + the consequence for the second -- "an inline tagged union spells its branch + in a form no variant-name heuristic recognizes". Branch classes made that + hole load-bearing: a split root's members are + ``function-after[_require_schema_required_group(), <Branch>]``, so every + error under one carried a Python validator's name into the buyer-facing + field path. """ if not isinstance(segment, str): return False if not segment: return False + if _CORE_SCHEMA_LABEL.match(segment): + return True # Class names start with an uppercase letter and contain only # alphanumerics. Reject ``snake_case`` field names. return segment[0].isupper() and segment.replace("_", "").isalnum() and "_" not in segment +def without_schema_labels(loc: Iterable[Any]) -> tuple[Any, ...]: + """``loc`` without pydantic's CORE-SCHEMA labels, keeping variant names. + + The two kinds of member label are not equally useful. A CamelCase class + name identifies which variant an error came from, which is what the + narrowing below groups by and what a caller reading + ``details.validation_errors`` can act on. A core-schema expression -- + ``function-after[_require_schema_required_group(), <Branch>]`` -- is + pydantic's internal spelling of a wrapper around that member and names a + repo-private validator; it is never useful and must not reach a buyer. + + So this drops the second and keeps the first. + + A projection dropping BOTH lived here for a day, applied at the four places + that join a ``loc`` into a buyer-facing field path. It was the wrong fix and + is deleted: the element it was dropping came from the union a split root + installs, and ``_conditions.name_the_combinator`` now reports ONE branch's + errors with the label already gone, so there is nothing left downstream to + recognise. A filter there could not have been right -- it decided membership + by SPELLING, treating any CamelCase segment as a member label, so a + legitimate CamelCase key in the buyer's document would have been deleted + from the path, and a genuine union's variant name -- the one thing a label + is good for -- was deleted from one. + """ + return tuple( + segment + for segment in loc + if not (isinstance(segment, str) and _CORE_SCHEMA_LABEL.match(segment)) + ) + + def _split_at_variant( loc: tuple[Any, ...], ) -> tuple[tuple[Any, ...], str, tuple[Any, ...]] | None: @@ -88,13 +142,24 @@ def _split_at_variant( return loc[:last_variant_idx], variant, loc[last_variant_idx + 1 :] -#: Cap on input list size. Beyond this we pass through unchanged — -#: narrowing is a UX feature, not correctness, and an attacker -#: submitting a request that triggers thousands of validation errors -#: shouldn't get to amplify CPU through O(N) bucketing logic. The cap -#: is generous enough that genuine union dumps (~30 errors for a 13- -#: variant asset union) never hit it. -_MAX_NARROW_INPUT_SIZE = 500 +# There is deliberately NO cap on input size. +# +# One was here -- 500 errors, above which the input was returned UNCHANGED -- +# on the reasoning that an attacker "shouldn't get to amplify CPU through O(N) +# bucketing logic". Two things were wrong with it. +# +# It made quality degrade exactly when error volume is highest, which is when a +# buyer can least afford a firehose: above the cap the wholesale-refusal error +# and every branch's duplicate reached the response untouched. And the cap was +# sized before branch classes multiplied every error by the branch count, so a +# legitimate 501-element array produced 1019 errors and silently crossed it. +# +# The premise does not survive measurement either. Narrowing is linear with a +# ~4-5us constant, flat from 300 to 150_000 errors (630ms at 150_000), so there +# is no amplification to guard against -- and pydantic has already done strictly +# more work to PRODUCE the errors than narrowing does to process them. A bound +# on total validation work belongs where the errors are generated, not as a +# correctness cliff here. def _asset_variant_index(loc: tuple[Any, ...]) -> int | None: @@ -113,7 +178,7 @@ def _asset_variant_index(loc: tuple[Any, ...]) -> int | None: return None -def narrow_union_errors( +def _narrow_union_errors( errors: Any, ) -> list[Any]: """Return a focused subset of ``errors`` for discriminated-union @@ -153,13 +218,6 @@ def narrow_union_errors( return [] errors_list = list(errors) - # DoS guard: don't process pathologically-large inputs. Below the - # cap, narrowing helps. Above it, we're either in a hostile - # request or a legitimately massive schema; either way, the - # narrowing UX win doesn't justify the CPU. - if len(errors_list) > _MAX_NARROW_INPUT_SIZE: - return errors_list - # Bucket errors by (prefix_before_variant) — every error sharing # the same prefix is contending for the same logical slot, and # different errors in the same bucket are different variants of @@ -223,6 +281,25 @@ def narrow_union_errors( return narrowed +def narrow_union_errors(errors: Any) -> list[Any]: + """:func:`_narrow_union_errors`, with pydantic's core-schema labels removed. + + The projection is on the way OUT, not on the way in. Stripping at entry was + tried and is wrong: the narrowing buckets errors BY their variant label, so + removing a label first collapses three branches into one bucket with nothing + to group by -- and a buyer then receives every branch's complaint instead of + the one branch that got furthest. Measured on a ``create_media_buy`` request + with a bad ``geo_countries`` entry: five errors where one is wanted. + + So the labels survive the narrowing and are dropped from what it returns: + one place, after the one consumer of them has run. + """ + return [ + dict(err, loc=without_schema_labels(err.get("loc", ()))) if isinstance(err, dict) else err + for err in _narrow_union_errors(errors) + ] + + #: Pydantic-2 error types that signal "this variant's discriminator #: didn't match the user's input". A variant whose error list contains #: ANY of these is eliminated from step 1's candidate pool. @@ -237,11 +314,34 @@ def narrow_union_errors( #: * ``union_tag_invalid`` — pydantic-2's "tag found but invalid for #: this union" code. Same semantic as ``union_tag_not_found`` for #: our purposes. +#: Error types that say "this variant wants a field the document does not +#: carry". A variant failing only these has not been reached at all; a sibling +#: with any other error type has been, and is the one worth reporting. +_REQUIREDNESS_TYPES = frozenset({"missing", "missing_argument", "missing_keyword_only_argument"}) + _DISCRIMINATOR_MISMATCH_TYPES = frozenset( {"literal_error", "union_tag_not_found", "union_tag_invalid"} ) +def _document_depth(errs: list[Any]) -> int: + """How far into the buyer's document this variant's errors reach. + + Depth is over :func:`without_schema_labels`, not raw ``loc``, so pydantic's + core-schema expression for a wrapped member is not counted as a step. + + It was written over a ``document_path`` that dropped CamelCase variant names + too. That projection is deleted -- see :func:`without_schema_labels`, which + records why: it decided membership by SPELLING, so it would delete a + legitimate CamelCase key from the buyer's path. The depth metric does not + need it. A split root's branch errors arrive with the label already gone, + and in a genuine union every variant carries one, so a surviving variant + name adds the same single step to every candidate and cancels in the + comparison this feeds. + """ + return max((len(without_schema_labels(e.get("loc", ()))) for e in errs), default=0) + + def _pick_winning_variant( per_variant: dict[str, list[Any]], ) -> str | None: @@ -256,6 +356,9 @@ def _pick_winning_variant( / AudioStack 60-line-dump fix — when the user provides ``asset_type='image'`` and ImageAsset's other fields fail, we surface ImageAsset errors only. + 1b. **Past requiredness among those**: a variant whose every error is a + missing required field loses to a sibling that got past its own + required fields and failed on content. 2. **Fewest errors among matched**: if multiple variants matched the discriminator, pick the one with fewest errors (closest fit to user's input shape). @@ -279,7 +382,56 @@ def _pick_winning_variant( if matched: if len(matched) == 1: return next(iter(matched.keys())) - # Multiple matched (rare — would mean two variants share the + # Requiredness, among the variants the discriminator admitted. A + # variant that failed only because the document lacks a field IT + # requires has not been reached; a sibling that got past its own + # required fields and failed on CONTENT is the one a caller can act on. + # + # This is the rule the union's wrapping validator already applies to + # the MESSAGE -- relabel to the combinator only when EVERY branch + # reports `missing` -- stated here for the error LIST so the two agree. + # Branch classes made it load-bearing: a split root's branches differ + # by requiredness and nothing else, so the discriminator rules cannot + # separate them and every branch's complaint reached the buyer. + # + # It runs AFTER the discriminator match, not before. Before, it + # overrode a matched discriminator, which is stronger evidence -- + # measured as three regressions in this module's own tests. + past_requiredness = { + variant: errs + for variant, errs in matched.items() + if any(e.get("type") not in _REQUIREDNESS_TYPES for e in errs) + } + if past_requiredness and len(past_requiredness) < len(matched): + if len(past_requiredness) == 1: + return next(iter(past_requiredness)) + matched = past_requiredness + # How far into the DOCUMENT each variant got, before counting errors. + # + # A variant that refuses the document wholesale reports at the root: + # one `value_error` saying the shape is not its own. A variant that + # accepted the shape and failed on a field reports at a deep path. So + # depth measures how far a branch actually read, which is what "closest + # fit" means -- whereas the error COUNT measures terseness, which a + # wholesale refusal maximises precisely by not looking at the shape. + # + # Measured on create_media_buy with a bad geo_countries entry: the + # branch refusing `packages` outright reports 1 error at depth 0, and + # the two branches carrying the real string_type error report 8 and 10 + # at depth 5. Counting errors handed the buyer the root; depth does not. + # + # Depth is read through `without_schema_labels`, so pydantic's own + # core-schema expression for a wrapped member never counts as a step, + # and nothing here knows the name of any validator -- a name-keyed rule + # would die on the next rename. + deepest = max(_document_depth(errs) for errs in matched.values()) + reached = { + variant: errs for variant, errs in matched.items() if _document_depth(errs) == deepest + } + if len(reached) == 1: + return next(iter(reached)) + matched = reached + # Multiple still matched (rare — would mean two variants share the # same discriminator literal). Pick the one with fewest # errors. sorted_matched = sorted(matched.items(), key=lambda kv: len(kv[1])) @@ -303,5 +455,6 @@ def _non_literal_score(item: tuple[str, list[Any]]) -> int: __all__ = [ + "without_schema_labels", "narrow_union_errors", ] diff --git a/src/adcp/types/legacy.py b/src/adcp/types/legacy.py index 222bca752..41455f7cf 100644 --- a/src/adcp/types/legacy.py +++ b/src/adcp/types/legacy.py @@ -13,6 +13,9 @@ ) from adcp.types.domains.core.creative_asset import CreativeAsset as LegacyCreativeAsset from adcp.types.domains.core.creative_filters import CreativeFilters as LegacyCreativeFilters +from adcp.types.domains.core.creative_manifest import ( + CreativeManifest as LegacyCreativeManifest, +) from adcp.types.domains.core.format import Format as LegacyFormat from adcp.types.domains.core.format_id import FormatReferenceStructuredObject from adcp.types.domains.core.package import Package as LegacyPackage @@ -22,6 +25,9 @@ from adcp.types.domains.core.product_format_declaration import ( ProductFormatDeclaration as LegacyGeneratedProductFormatDeclaration, ) +from adcp.types.domains.core.proposal import ( + TotalBudgetGuidance as LegacyProposalTotalBudgetGuidance, +) from adcp.types.domains.creative.get_creative_delivery_response import ( GetCreativeDeliveryResponse as LegacyGetCreativeDeliveryResponse, ) @@ -152,19 +158,19 @@ LegacyFormatReferenceStructuredObject = FormatReferenceStructuredObject LegacyProductFormatDeclaration = LegacyGeneratedProductFormatDeclaration -# One semantic name per ARM of every response union whose tool reaches this +# One semantic name per BRANCH of every response union whose tool reaches this # surface. Not a curated subset of them (#1399): where the bare public name for -# an arm is the canonical SUBCLASS, the generated arm itself has no public +# an branch is the canonical SUBCLASS, the generated branch itself has no public # spelling at all without one of these, and a seller reading an off-the-wire # response can then only name it through ``adcp.types.domains.<domain>.*`` — # which is the private tree this release exists to retire. # # The set used to be curated, and it had five holes at once: # ``LegacyUpdateMediaBuySubmittedResponse`` and all three -# ``LegacyCreateMediaBuy*Response`` arms were absent while +# ``LegacyCreateMediaBuy*Response`` branches were absent while # ``LegacyUpdateMediaBuy{Success,Error}Response`` shipped, and the generator had -# already opened a fifth by adding a fourth ``preview_creative`` arm that nobody -# named. ``tests/test_legacy_surface_names_every_arm.py`` derives the required +# already opened a fifth by adding a fourth ``preview_creative`` branch that nobody +# named. ``tests/test_legacy_surface_names_every_branch.py`` derives the required # set from the generated response unions, so a sixth fails the build instead of # reaching an adopter. LegacyBuildCreativeSuccessResponse = LegacyBuildCreativeResponse1 @@ -177,6 +183,9 @@ LegacyPreviewCreativeBatchResponse = LegacyPreviewCreativeResponse2 LegacyPreviewCreativeVariantResponse = LegacyPreviewCreativeResponse3 LegacyPreviewCreativeSubmittedResponse = LegacyPreviewCreativeResponse4 +LegacyUpdateMediaBuySuccessResponse = LegacyUpdateMediaBuyResponse1 +LegacyUpdateMediaBuyErrorResponse = LegacyUpdateMediaBuyResponse2 +LegacyUpdateMediaBuySubmittedResponse = LegacyUpdateMediaBuyResponse3 __all__ = [ "infer_asset_type", @@ -208,6 +217,7 @@ "LegacyCreateMediaBuySuccessResponse", "LegacyCreativeAsset", "LegacyCreativeFilters", + "LegacyCreativeManifest", "LegacyFormat", "LegacyFormatId", "LegacyFormatReferenceStructuredObject", @@ -237,11 +247,15 @@ "LegacyProduct", "LegacyProductFilters", "LegacyProductFormatDeclaration", + "LegacyProposalTotalBudgetGuidance", "LegacySyncCreativesRequest", "LegacySyncCreativesResponse", + "LegacyUpdateMediaBuyErrorResponse", "LegacyUpdateMediaBuyRequest", "LegacyUpdateMediaBuyResponse", "LegacyUpdateMediaBuyResponse1", "LegacyUpdateMediaBuyResponse2", "LegacyUpdateMediaBuyResponse3", + "LegacyUpdateMediaBuySubmittedResponse", + "LegacyUpdateMediaBuySuccessResponse", ] diff --git a/src/adcp/types/projections.py b/src/adcp/types/projections.py index 4a6018f3d..275e062f7 100644 --- a/src/adcp/types/projections.py +++ b/src/adcp/types/projections.py @@ -30,8 +30,21 @@ from pydantic import ConfigDict, Field, field_validator from adcp._version import normalize_to_release_precision -from adcp.types import Account, BusinessEntity, NotificationAuthentication, NotificationConfig + +# From the modules that DECLARE them, not from ``adcp.types``. The package's +# surface is lazy per NAME only for a submodule; a type name routes through one +# ``_eager`` module that binds the whole graph, and ``_eager`` imports this +# module (``_eager.py``'s import of ``AccountResponse`` is the only importer of +# ``projections`` in the package). So resolving a type name HERE, at this +# module's own import time, re-entered a module still initializing and +# ``import adcp.types.projections`` raised ImportError unless something had +# imported ``adcp.types`` first. Measured: these four are the identical objects +# ``adcp.types`` exposes, and each declaring module imports standalone. +from adcp.types.aliases import NotificationAuthentication from adcp.types.capabilities import GeoPostalAreas, LegacyPostalCodeSystem +from adcp.types.domains.core.account import Account +from adcp.types.domains.core.business_entity import BusinessEntity +from adcp.types.domains.core.notification_config import NotificationConfig from adcp.types.variants import SchemaVariant _NATIVE_TO_LEGACY_POSTAL: dict[tuple[str, str], LegacyPostalCodeSystem] = { diff --git a/src/adcp/types/response_dispatch.py b/src/adcp/types/response_dispatch.py deleted file mode 100644 index 074f7282c..000000000 --- a/src/adcp/types/response_dispatch.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Compatibility dispatch for constructible generated response bases.""" - -from __future__ import annotations - -from typing import Any, cast - -from pydantic import BaseModel, TypeAdapter -from typing_extensions import Self - - -def _model_validate_kwargs( - *, - strict: bool | None, - extra: Any, - from_attributes: bool | None, - context: Any, - by_alias: bool | None, - by_name: bool | None, -) -> dict[str, Any]: - """Build arguments accepted by both early and current Pydantic 2.x.""" - kwargs: dict[str, Any] = { - "strict": strict, - "from_attributes": from_attributes, - "context": context, - } - # These keywords were added after Pydantic 2.0. Omitting their default - # ``None`` retains the older model_validate() compatibility contract. - if extra is not None: - kwargs["extra"] = extra - if by_alias is not None: - kwargs["by_alias"] = by_alias - if by_name is not None: - kwargs["by_name"] = by_name - return kwargs - - -def _model_validate_json_kwargs( - *, - strict: bool | None, - extra: Any, - context: Any, - by_alias: bool | None, - by_name: bool | None, -) -> dict[str, Any]: - """Build JSON-validation arguments accepted across Pydantic 2.x.""" - kwargs: dict[str, Any] = {"strict": strict, "context": context} - if extra is not None: - kwargs["extra"] = extra - if by_alias is not None: - kwargs["by_alias"] = by_alias - if by_name is not None: - kwargs["by_name"] = by_name - return kwargs - - -class ResponseArmDispatchMixin: - """Validate a stable response base through its generated schema arms. - - Some public response names predate code generation emitting a union of - numbered response-arm models. The generated compatibility base keeps the - public name constructible, while this mixin makes ``Base.model_validate`` - preserve the arm-specific fields that arrived on the wire. - """ - - @classmethod - def _response_arm_models(cls) -> tuple[type[BaseModel], ...]: - """Return the generated models that define this response's wire arms.""" - return () - - @classmethod - def model_validate( - cls: type[Self], - obj: Any, - *, - strict: bool | None = None, - extra: Any = None, - from_attributes: bool | None = None, - context: Any = None, - by_alias: bool | None = None, - by_name: bool | None = None, - ) -> Self: - """Validate stable bases as their matching generated response arm.""" - if isinstance(obj, cls): - return obj - - arms = cls._response_arm_models() - kwargs = _model_validate_kwargs( - strict=strict, - extra=extra, - from_attributes=from_attributes, - context=context, - by_alias=by_alias, - by_name=by_name, - ) - # Concrete arms inherit this method too; their own API must retain - # single-arm validation rather than dispatching to a sibling arm. - if not arms or cls in cast(Any, arms): - parent_model = cast(Any, super()) - return cast(Self, parent_model.model_validate(obj, **kwargs)) - - union_type: Any = arms[0] - for arm in arms[1:]: - union_type |= arm - - return cast(Self, TypeAdapter(union_type).validate_python(obj, **kwargs)) - - @classmethod - def model_validate_json( - cls: type[Self], - json_data: str | bytes | bytearray, - *, - strict: bool | None = None, - extra: Any = None, - context: Any = None, - by_alias: bool | None = None, - by_name: bool | None = None, - ) -> Self: - """Validate JSON through response arms without dropping their fields.""" - arms = cls._response_arm_models() - kwargs = _model_validate_json_kwargs( - strict=strict, - extra=extra, - context=context, - by_alias=by_alias, - by_name=by_name, - ) - if not arms or cls in cast(Any, arms): - parent_model = cast(Any, super()) - return cast(Self, parent_model.model_validate_json(json_data, **kwargs)) - - union_type: Any = arms[0] - for arm in arms[1:]: - union_type |= arm - - return cast(Self, TypeAdapter(union_type).validate_json(json_data, **kwargs)) diff --git a/src/adcp/utils/preview_cache.py b/src/adcp/utils/preview_cache.py index 8be9d7224..9d39129ea 100644 --- a/src/adcp/utils/preview_cache.py +++ b/src/adcp/utils/preview_cache.py @@ -527,8 +527,12 @@ def _create_sample_manifest_for_format(fmt: Any) -> Any | None: Returns: Sample CreativeManifest, or None if unable to create one """ - from adcp.types import CreativeManifest, ImageContent, UrlContent - from adcp.types._generated import CreativeManifest as LegacyCreativeManifest + from adcp.types import ( + CreativeManifest, + CreativeManifestNamedFormatManifest, + ImageContent, + UrlContent, + ) from adcp.utils.format_assets import get_required_assets required_assets = get_required_assets(fmt) @@ -588,6 +592,11 @@ def _create_sample_manifest_for_format(fmt: Any) -> Any | None: if hasattr(fmt, "format_kind") and not hasattr(fmt, "format_id"): option_id = getattr(fmt, "format_option_id", None) option_ref = {"scope": "product", "format_option_id": option_id} if option_id else None + # ``CreativeManifest`` here is the canonical model, which subclasses the + # root's canonical-format arm -- so it already IS the shape this branch's + # kwargs name. The hasattr pair above stays: it discriminates the + # FORMAT's shape, not this manifest's, and no branch of the manifest + # answers it. return CreativeManifest( format_kind=fmt.format_kind, format_option_ref=option_ref, @@ -605,7 +614,10 @@ def _create_sample_manifest_for_format(fmt: Any) -> Any | None: ) for asset_id, asset in assets.items() } - return LegacyCreativeManifest.model_validate( + # The document names ``format_id``, so it is the named-format arm. Parsed + # through the arm rather than the alias: an alias has no model_validate, and + # naming the arm says what the document already said. + return CreativeManifestNamedFormatManifest.model_validate( {"format_id": fmt.format_id, "assets": legacy_assets} ) @@ -621,8 +633,11 @@ def _create_sample_manifest_for_format_id(format_id: FormatId, product: Product) Returns: Sample CreativeManifest with placeholder assets """ - from adcp.types import ImageContent, UrlContent - from adcp.types._generated import CreativeManifest + from adcp.types import ( + CreativeManifestNamedFormatManifest, + ImageContent, + UrlContent, + ) assets = { "primary_asset": ImageContent( @@ -633,7 +648,9 @@ def _create_sample_manifest_for_format_id(format_id: FormatId, product: Product) "clickthrough_url": UrlContent(url="https://example.com"), } - return CreativeManifest(format_id=format_id, promoted_offering=product.name, assets=assets) + return CreativeManifestNamedFormatManifest( + format_id=format_id, promoted_offering=product.name, assets=assets + ) def _create_sample_asset(asset_type: str | None) -> Any: diff --git a/src/adcp/validation/pydantic_errors.py b/src/adcp/validation/pydantic_errors.py new file mode 100644 index 000000000..5da352076 --- /dev/null +++ b/src/adcp/validation/pydantic_errors.py @@ -0,0 +1,281 @@ +"""Project a pydantic ``ValidationError`` onto the AdCP ``issues[]`` shape. + +``str(ValidationError)`` renders pydantic's full diagnostic, which carries +``input_value=`` — the offending payload value — and ``type=``, pydantic's +internal error-type name. Neither belongs in a protocol error: the value is +the peer's own data reflected back through a wire envelope that logs and +intermediaries see, and the type name is a producer implementation detail +that no AdCP consumer can act on. + +What a peer IS owed is in ``core/error.json`` (AdCP 3.2) under ``issues[]``: +the RFC 6901 ``pointer`` to the offending field, a ``message`` saying why it +was rejected, and a ``keyword`` "drawn from the JSON Schema vocabulary … +Implementers SHOULD use the validator's native keyword name; do not invent +custom values here." Pydantic is not a JSON Schema validator, so its native +names (``model_type``, ``missing``) are not in that vocabulary and are mapped +here to the keyword the equivalent JSON Schema rule would have reported. + +The result feeds :func:`adcp.validation.schema_errors.build_adcp_validation_error_payload` +— the same builder the jsonschema oracle path uses — so both routes to the +``VALIDATION_ERROR`` envelope produce one shape. +""" + +from __future__ import annotations + +from typing import Any + +from jsonschema import Draft7Validator +from pydantic import ValidationError + +from adcp.validation.schema_validator import ( + _COMPOSITION_KEYWORDS, + UNSET, + ValidationIssue, + _path_to_pointer, + safe_message_for, +) + +#: The keyword vocabulary core/error.json points ``issues[].keyword`` at, taken +#: from the validator that implements it rather than restated here — a hand-kept +#: copy is a second declaration of someone else's list. +_JSON_SCHEMA_KEYWORDS = frozenset(Draft7Validator.VALIDATORS) + +# Pydantic error ``type`` -> the JSON Schema keyword the same rejection would +# carry. Only the structural rejections appear here; see ``_keyword`` for the +# families handled by suffix and for the deliberate fallback. +_KEYWORD_BY_TYPE: dict[str, str] = { + # Presence + "missing": "required", + "missing_argument": "required", + "missing_keyword_only_argument": "required", + "missing_positional_only_argument": "required", + "extra_forbidden": "additionalProperties", + # Closed sets + "literal_error": "enum", + "enum": "enum", + # Strings + "string_pattern_mismatch": "pattern", + "string_too_short": "minLength", + "string_too_long": "maxLength", + # Collections + "too_short": "minItems", + "too_long": "maxItems", + # Numbers + "greater_than": "exclusiveMinimum", + "greater_than_equal": "minimum", + "less_than": "exclusiveMaximum", + "less_than_equal": "maximum", + "multiple_of": "multipleOf", + # Discriminated unions + "union_tag_invalid": "oneOf", + "union_tag_not_found": "oneOf", +} + +# Pydantic ``type`` -> the JSON type name a schema would have declared. Drives +# the ``expected type 'x'`` wording without naming the pydantic model class +# that pydantic's own message would have named. +_JSON_TYPE_BY_TYPE: dict[str, str] = { + "model_type": "object", + "model_attributes_type": "object", + "dataclass_type": "object", + "dict_type": "object", + "list_type": "array", + "tuple_type": "array", + "set_type": "array", + "frozen_set_type": "array", + "string_type": "string", + "int_type": "integer", + "int_parsing": "integer", + "int_parsing_size": "integer", + "int_from_float": "integer", + "float_type": "number", + "float_parsing": "number", + "decimal_type": "number", + "decimal_parsing": "number", + "bool_type": "boolean", + "bool_parsing": "boolean", + "none_required": "null", +} + +# Pydantic ``type`` values whose rejection is a string FORMAT failure. +_FORMAT_TYPES = frozenset( + { + "url_parsing", + "url_syntax_violation", + "url_type", + "url_too_long", + "url_scheme", + "uuid_parsing", + "uuid_type", + "uuid_version", + "date_parsing", + "date_type", + "date_from_datetime_parsing", + "datetime_parsing", + "datetime_type", + "datetime_from_date_parsing", + "time_parsing", + "time_type", + "timedelta_parsing", + "timedelta_type", + } +) + +# The ctx key each bounded keyword stores its SCHEMA-SIDE bound under. Only +# these are read: ``ctx`` also carries ``actual_length``, which is a fact +# about the peer's payload and so is never surfaced. +_BOUND_CTX_KEY: dict[str, str] = { + "minLength": "min_length", + "maxLength": "max_length", + "minItems": "min_length", + "maxItems": "max_length", + "exclusiveMinimum": "gt", + "minimum": "ge", + "exclusiveMaximum": "lt", + "maximum": "le", + "multipleOf": "multiple_of", +} + +# Pydantic reports a rule raised by a model's own validator as ``value_error`` +# (or ``assertion_error``), and the message is ``str()`` of whatever that +# validator raised. Those texts interpolate caller values in this SDK -- +# ``types/core.py:96`` raises ``f"timeout must be positive, got: {v}"`` -- so +# the message cannot be passed through. CEILING: the peer learns the pointer +# and that a declared rule rejected the field, but not WHICH rule. Lifting it +# means giving those validators structured errors (a keyword plus value-free +# facts) instead of an interpolated sentence; until then this is the only +# treatment that is provably value-free. Tracked as salesagent-8qela.19. +_OPAQUE_TYPES = frozenset({"value_error", "assertion_error"}) + + +class _Unresolved: + """Sentinel: no document supplied, so ``loc`` is used as given.""" + + +_UNRESOLVED = _Unresolved() + + +def _keyword(error_type: str) -> str: + """The JSON Schema keyword standing in for a pydantic error type. + + A type that is ALREADY a keyword passes through. The generated models + raise ``PydanticCustomError('allOf', ...)`` for every schema conditional + — 592 sites across ``allOf``, ``anyOf`` and ``oneOf`` — so mapping those + to a fallback discards the one thing ``issues[].keyword`` is for. + """ + if error_type in _JSON_SCHEMA_KEYWORDS: + return error_type + if error_type in _KEYWORD_BY_TYPE: + return _KEYWORD_BY_TYPE[error_type] + if error_type in _JSON_TYPE_BY_TYPE: + return "type" + if error_type in _FORMAT_TYPES: + return "format" + # ``_format_error`` in schema_validator uses this same spelling when a + # jsonschema error carries no keyword. Keeping one word for "no keyword + # applies" means a consumer matching on it sees both lanes alike. + return "validation" + + +def _message(entry: dict[str, Any], keyword: str) -> str: + """Value-free wording for one pydantic error entry.""" + error_type = str(entry.get("type", "")) + if error_type in _OPAQUE_TYPES: + return "a declared constraint on this field rejected the value" + if keyword == "required": + name = entry["loc"][-1] if entry.get("loc") else None + if name is None: + return safe_message_for(keyword) + return safe_message_for(keyword, required_message=f"{str(name)!r} is a required property") + if keyword == "type": + return safe_message_for(keyword, bound=_JSON_TYPE_BY_TYPE[error_type]) + ctx = entry.get("ctx") or {} + if keyword in _COMPOSITION_KEYWORDS: + # The compiled validators compose the field names from the schema node + # and put them in the context -- ``name_the_combinator`` carries the + # branch groups' fields, ``absent`` the forbidden names. Dropping the + # context left the buyer "anyOf composition failed", which names the + # rule and not the place. A conjunct whose context is EMPTY renders as + # before: the generator not supplying them is a separate defect + # (salesagent-8qela.49) and inventing names here would be worse. + return safe_message_for(keyword, fields=ctx.get("fields", UNSET)) + if keyword in _BOUND_CTX_KEY: + ctx_key = _BOUND_CTX_KEY[keyword] + if ctx_key in ctx: + return safe_message_for(keyword, bound=ctx[ctx_key]) + return safe_message_for(keyword) + # ``literal_error`` ctx carries ``expected`` as a rendered sentence + # ("'p' or 'q'"), not a list, so there is no reliable option count to + # report; ``safe_message_for`` drops the count rather than guess one. + return safe_message_for(keyword) + + +def _document_path(loc: tuple[Any, ...], payload: Any) -> list[Any]: + """The elements of ``loc`` that address ``payload``. + + pydantic's ``loc`` is not a document path. It carries synthetic elements + that name the validator or the selected class — ``function-after[...]`` for + a wrapping validator, and the branch's class name for any union, which + pydantic emits with no wrapper involved. Those address nothing in the + submitted document, so an RFC 6901 pointer built from them does not + resolve against what the peer sent. + + Resolution rather than pattern-matching is deliberate: a union branch name + is a plain string indistinguishable from a key, so no list of spellings can + be complete. An element is kept when the document actually has it. The last + element is kept regardless, since a ``missing`` failure names a key that is + absent by definition. + """ + kept: list[Any] = [] + node = payload + for index, element in enumerate(loc): + is_last = index == len(loc) - 1 + if isinstance(node, dict) and element in node: + kept.append(element) + node = node[element] + elif ( + isinstance(node, list) + and isinstance(element, int) + and -len(node) <= element < len(node) + ): + kept.append(element) + node = node[element] + elif is_last and isinstance(element, str): + # A key the document does not have — the whole point of `missing`. + kept.append(element) + return kept + + +def issues_from_validation_error( + error: ValidationError, payload: Any = _UNRESOLVED +) -> list[ValidationIssue]: + """Every refusal in ``error``, as value-free :class:`ValidationIssue` rows. + + The ``pointer`` is RFC 6901 over pydantic's ``loc``; the ``keyword`` is + the JSON Schema keyword standing in for pydantic's internal error type; + the ``message`` is built from schema-side facts only. No field carries a + payload value or a pydantic type name. + + Pass ``payload`` — the document that was validated — to resolve ``loc`` + against it, which drops the synthetic elements pydantic adds for a union + branch or a wrapping validator. Without it the pointer is whatever ``loc`` + said, which may name a class rather than a field. + """ + issues: list[ValidationIssue] = [] + for entry in error.errors(): + keyword = _keyword(str(entry.get("type", ""))) + loc = tuple(entry.get("loc") or ()) + path = list(loc) if payload is _UNRESOLVED else _document_path(loc, payload) + issues.append( + ValidationIssue( + pointer=_path_to_pointer(path), + message=_message(dict(entry), keyword), + keyword=keyword, + # A pydantic model is not a schema document, so there is no + # schema tree path to report. ``issues[].schemaPath`` is + # optional in core/error.json; empty is how this lane says + # "no schema path", rather than inventing one. + schema_path="", + ) + ) + return issues diff --git a/src/adcp/validation/schema_errors.py b/src/adcp/validation/schema_errors.py index f42c93d05..7295e9b38 100644 --- a/src/adcp/validation/schema_errors.py +++ b/src/adcp/validation/schema_errors.py @@ -10,6 +10,7 @@ SchemaValidationError, ValidationIssue, _issue_to_wire, + pointer_to_field, ) @@ -53,10 +54,12 @@ def build_adcp_validation_error_payload( ) -> dict[str, Any]: """Serialize issues into the kwargs expected by the AdCP ``Error`` model. - Returns a dict with ``code`` / ``message`` / optional ``field`` / - ``details`` keys — ready to splat into - ``Error(**build_adcp_validation_error_payload(...))`` or into the - server's ``adcp_error`` response envelope. + Returns ``code``, ``message``, ``recovery`` and ``details``, plus + ``issues`` and ``field`` when there is at least one issue — ready to splat + into ``Error(**build_adcp_validation_error_payload(...))`` or into the + server's ``adcp_error`` response envelope. The one owner of this envelope: + both the jsonschema oracle and the pydantic projection reach the wire + through here, so neither can disagree about its shape. Messages on every ``ValidationIssue`` are already sanitized (see :func:`adcp.validation.schema_validator._safe_message`) — they do @@ -64,29 +67,76 @@ def build_adcp_validation_error_payload( bearer tokens / PII / prompt-injection strings from the offending payload back to the peer. """ + # ``field`` IS ``issues[0].pointer`` translated, and ``core/error.json`` + # types it as a "Field path ... in JSONPath-lite format (e.g. + # 'packages[0].targeting')". The document root has no such spelling -- it + # translates to the empty string -- so an entry pointing at the root cannot + # lead the list without leaving ``field`` unpopulated, and a present + # ``field`` is graded rather than advisory (``field_present`` on + # ``adcp_error.field`` in the pinned conformance suite's + # ``refine_finalize_exclusivity`` and ``proposal_finalize`` scenarios). + # + # A root entry still SHIPS. The pin's own envelope in + # ``protocol/calling-an-agent.mdx`` lists ``anyOf``/``oneOf`` among the + # legal ``issues[].keyword`` values and calls a union entry's ``variants[]`` + # "the fastest recovery path" -- what it never does is place one at the + # document root, because every union it illustrates sits at a FIELD + # (``/account``). A split root is the case it does not cover, so only the + # ORDER is decided here. Stable, so each group keeps its own order -- + # including the oracle's shallowest-first sort among located entries, which + # is the order the pin's example shows (``/account`` before + # ``/brand/domain``). + issues = sorted(issues, key=lambda issue: not pointer_to_field(issue.pointer)) first = issues[0] if issues else None if first is not None: message = f"{tool} {side} failed schema validation at {first.pointer}: {first.message}" else: message = f"{tool} {side} failed schema validation" + wire_issues = [_issue_to_wire(i) for i in issues] payload: dict[str, Any] = { "code": "VALIDATION_ERROR", "message": message, + # core/error.json: details.issues is the pre-3.1 mirror of the + # top-level `issues`, which new consumers SHOULD prefer. "details": { "tool": tool, "side": side, - "issues": [_issue_to_wire(i) for i in issues], + "issues": wire_issues, }, + # 3.2 producers MUST populate `recovery`; a malformed request is the + # enum's own example of correctable. + "recovery": "correctable", } + if wire_issues: + payload["issues"] = wire_issues if any(issue.keyword == "schema_unavailable" for issue in issues): from adcp.validation.envelope import SUPPORTED_WIRE_VERSIONS payload["code"] = "VERSION_UNSUPPORTED" - payload["field"] = "adcp_version" - payload["recovery"] = "correctable" payload["details"]["supported_versions"] = list(SUPPORTED_WIRE_VERSIONS) - return payload - if first is not None and first.pointer: - payload["field"] = first.pointer + # core/error.json: when issues[] is present, `field` is issues[0].pointer + # translated to JSONPath-lite. One translator so every lane agrees on the + # format, VERSION_UNSUPPORTED included. + # + # PRESENT WHENEVER ``issues`` IS, including when the translation is the empty + # string. The pin's rule is mechanical and its condition is the presence of + # ``issues``, not the shape of the pointer: "When `issues` is present, sellers + # MUST also populate `field` from `issues[0]` ... MUST (not SHOULD) so + # consumers reading `field` get deterministic behavior across sellers". + # ``field`` carries no ``minLength``, so the root's empty translation is a + # legal value for it. + # + # This used to be gated on the translation being truthy, which omitted + # ``field`` whenever EVERY issue sat at the document root -- measured on four + # create_media_buy documents, all four. The gate's rationale was that the root + # has no field path to name and ``field`` is not in error.json's ``required`` + # list; both are true and neither is the condition the pin states. + # Deterministic presence is the thing a pre-3.1 consumer reading ``field`` + # alone cannot get from an absent key (salesagent-8qela.125). + # + # ``first is None`` still omits both, which is the case the pin does not + # reach: no ``issues`` means no MUST and nothing to translate. + if first is not None: + payload["field"] = pointer_to_field(first.pointer) return payload diff --git a/src/adcp/validation/schema_validator.py b/src/adcp/validation/schema_validator.py index d97308780..04ebf73e6 100644 --- a/src/adcp/validation/schema_validator.py +++ b/src/adcp/validation/schema_validator.py @@ -67,6 +67,47 @@ class ValidationIssue: hint: str | None = None +class SchemaRuleError(ValueError): + """A conditional invariant the generated model cannot express, refused with + the vocabulary term that names the rule and a pointer to the offending field. + + ``core/error.json`` (AdCP 3.2.1) makes ``keyword`` required on every + ``issues[]`` row and describes it as "Schema keyword that rejected the + payload, drawn from the JSON Schema vocabulary ... Implementers SHOULD use + the validator's native keyword name; do not invent custom values here". Its + stated purpose is that an agent can "pattern-match on rejection class without + parsing message text". A hand-written refusal therefore carries its own term, + because one constant over a heterogeneous set of rules conveys no class at + all — it says only that something refused, which ``code`` already says. + + ``pointer`` is carried for the same reason: an agent that cannot locate the + rejection is back to parsing the message. + + A SITE THAT ENFORCES TWO RULES CARRIES TWO ISSUES. ``issues`` is an array, so + a refusal that is both a missing-required and a forbidden-present keeps the + class of each half instead of collapsing to whichever term fits worse. + + ``str()`` joins the messages, so a caller that only logs the exception still + reads the same text it did before. + """ + + def __init__(self, *issues: ValidationIssue) -> None: + if not issues: + raise TypeError("a SchemaRuleError names at least one violated rule") + self.issues: tuple[ValidationIssue, ...] = issues + super().__init__("; ".join(issue.message for issue in issues)) + + +def rule(keyword: str, pointer: str, message: str) -> ValidationIssue: + """One violated rule, for :class:`SchemaRuleError`. + + ``schema_path`` is empty: these rules live in the schema's ``if``/``then`` + and negative intersections, which the generated model does not retain a path + into, and an invented path would be worse than an absent one. + """ + return ValidationIssue(pointer=pointer, message=message, keyword=keyword, schema_path="") + + @dataclass(frozen=True) class ValidationOutcome: valid: bool @@ -124,8 +165,7 @@ def __init__( first = issues[0] if issues else None if first is not None: message = ( - f"{tool} {side} failed schema validation at " - f"{first.pointer}: {first.message}" + f"{tool} {side} failed schema validation at {first.pointer}: {first.message}" ) else: message = f"{tool} {side} failed schema validation" @@ -211,30 +251,84 @@ def escape(seg: Any) -> str: return "/" + "/".join(escape(seg) for seg in path) -def _safe_message(err: Any, keyword: str) -> str: - """Value-free description of the failure. - - Never echoes the offending payload value — that would leak bearer - tokens, PII, or prompt-injection strings through the error envelope. - Keeps only the structural facts the caller needs to locate the - offending field and understand why the schema rejected it. +#: A segment JSONPath-lite can write after a ``.`` unambiguously. Schema-declared +#: AdCP names are all of this shape; a segment that is not comes from an open map +#: in the payload (``ext``, ``details``, a targeting key) where a peer chose it. +_BARE_SEGMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_INDEX_SEGMENT = re.compile(r"^(?:0|[1-9][0-9]*)$") +_JSONPATH_TOKEN = re.compile( + r""" + \[ (?P<index>\d+) \] # [0] + | \[ ' (?P<quoted>(?:[^'\\]|\\.)*) ' \] # ['a.b'] + | \.? (?P<bare>[A-Za-z_][A-Za-z0-9_]*) # name or .name + """, + re.VERBOSE, +) + + +def pointer_to_field(pointer: str) -> str: + """An RFC 6901 pointer as the JSONPath-lite ``error.field`` uses. + + ``/packages/0/targeting`` -> ``packages[0].targeting``. Required by + ``core/error.json``: when ``issues[]`` is present, ``field`` is + ``issues[0].pointer`` translated. + + The target format is the lossy one, and the pin says so where it defines + ``issues[].pointer`` — RFC 6901 is "standardized and unambiguous on keys + containing ``/`` or ``~``", "differs from the legacy top-level ``field`` + which uses JSONPath-lite", and sellers "MUST translate the format" + regardless. So dot notation cannot express three pointer shapes: + + * a segment that is not a bare identifier is bracket-quoted + (``/a.b`` -> ``['a.b']``), which covers keys containing ``.`` and the + ``~1``/``~0`` escapes for ``/`` and ``~``. Standard JSONPath member + access, beyond the examples the spec gives; + * an all-digit segment becomes an index (``[0]``), so a digit-string + object key is indistinguishable from one — the one lossy case; + * root (``"/"``, this repo's spelling for the empty path) becomes ``""``. """ - if keyword == "required": - # ``err.message`` contains a schema-declared key name, not user - # data. Pattern: "'X' is a required property". Safe to pass through. - return str(err.message) - if keyword == "type": - expected = err.validator_value - return f"expected type {expected!r}" - if keyword == "enum": - try: - size = len(err.validator_value) - except TypeError: - size = 0 - return f"value not in allowed enum ({size} option{'s' if size != 1 else ''})" - if keyword == "const": - return "value does not match the required constant" - if keyword in { + if pointer in ("", "/"): + return "" + out: list[str] = [] + for raw in pointer.lstrip("/").split("/"): + segment = raw.replace("~1", "/").replace("~0", "~") + if _INDEX_SEGMENT.match(segment): + out.append(f"[{segment}]") + elif _BARE_SEGMENT.match(segment): + out.append(segment if not out else f".{segment}") + else: + escaped = segment.replace("\\", "\\\\").replace("'", "\\'") + out.append(f"['{escaped}']") + return "".join(out) + + +def field_to_pointer(field: str) -> str: + """The RFC 6901 pointer a JSONPath-lite ``error.field`` came from. + + The inverse of :func:`pointer_to_field`; the two are asserted inverses in + ``tests/test_error_field_jsonpath_lite.py``, which is what covers the + escape handling in both directions. + """ + if not field: + return "/" + segments: list[str] = [] + position = 0 + while position < len(field): + match = _JSONPATH_TOKEN.match(field, position) + if match is None: + raise ValueError(f"not JSONPath-lite at offset {position}: {field!r}") + position = match.end() + if (index := match.group("index")) is not None: + segments.append(index) + elif (quoted := match.group("quoted")) is not None: + segments.append(re.sub(r"\\(.)", r"\1", quoted)) + else: + segments.append(match.group("bare")) + return _path_to_pointer(segments) + + +_BOUNDED_KEYWORDS = frozenset( + { "minLength", "maxLength", "minimum", @@ -246,19 +340,177 @@ def _safe_message(err: Any, keyword: str) -> str: "minProperties", "maxProperties", "multipleOf", - }: - return f"{keyword} constraint failed (bound={err.validator_value!r})" + } +) + + +#: The four JSON Schema keywords whose failure is a composition rather than a +#: single constraint. Named once: ``safe_message_for`` renders them together and +#: both lanes ask the same question of a keyword before deriving field names. +_COMPOSITION_KEYWORDS = frozenset({"oneOf", "anyOf", "allOf", "not"}) + + +class _Unset: + """Sentinel: a fact the caller's error shape does not carry. + + Distinct from ``None``, which a schema can legitimately supply as a + bound — so the two render differently. + """ + + +UNSET = _Unset() + + +def _render_fields(fields: Any) -> str: + """The field names a refusal cites, as one phrase. + + Accepts the comma-joined string the compiled validators put in their error + context and any iterable of names, so each lane hands over whatever shape it + already has rather than converting at the call site. + """ + if isinstance(fields, str): + names = [part.strip() for part in fields.split(",")] + else: + names = [str(name).strip() for name in fields] + return ", ".join(sorted({name for name in names if name})) + + +def safe_message_for( + keyword: str, + *, + required_message: str | None = None, + bound: Any = UNSET, + enum_size: int | None = None, + format_name: Any = UNSET, + fields: Any = UNSET, +) -> str: + """Value-free wording for a rejection, given only structural facts. + + The one owner of how a refusal is PHRASED to a peer. Takes facts + rather than a validator's error object so both projections share it: + :func:`_safe_message` extracts them from a ``jsonschema`` error, and + :mod:`adcp.validation.pydantic_errors` extracts them from a pydantic + one. Neither passes a payload value in, so neither can leak one out. + """ + if keyword == "required": + # A schema-declared key name, not user data. Pattern: + # "'X' is a required property". + return required_message or "required constraint failed" + if keyword == "type": + if bound is UNSET: + return "value is the wrong type" + return f"expected type {bound!r}" + if keyword == "enum": + if enum_size is None: + return "value not in allowed enum" + return f"value not in allowed enum ({enum_size} option{'s' if enum_size != 1 else ''})" + if keyword == "const": + return "value does not match the required constant" + if keyword in _BOUNDED_KEYWORDS: + if bound is UNSET: + return f"{keyword} constraint failed" + return f"{keyword} constraint failed (bound={bound!r})" if keyword == "pattern": return "string does not match required pattern" if keyword == "format": - return f"value does not match required format ({err.validator_value!r})" + if format_name is UNSET: + return "value does not match required format" + return f"value does not match required format ({format_name!r})" if keyword == "additionalProperties": return "unexpected property" - if keyword in {"oneOf", "anyOf", "allOf", "not"}: + if keyword in _COMPOSITION_KEYWORDS: + # The names a composition constrains are declared by the SCHEMA, so + # citing them is the same move as passing a ``required`` failure's key + # name through above — and it is what turns "allOf composition failed" + # from a rule identifier into something a buyer can act on. Nothing from + # the instance reaches here: each lane derives the names from a schema + # node or from the context the generator composed out of one. + if rendered := _render_fields(fields) if fields is not UNSET else "": + return f"{keyword} composition failed (fields: {rendered})" return f"{keyword} composition failed" return f"{keyword} constraint failed" +def _declared_field_names(node: Any) -> tuple[str, ...]: + """Every property name a schema node declares, sorted. + + Schema-derived and instance-free by construction: the only thing read is + the node, so a value the peer sent cannot reach the result. Mirrors + ``scripts/compile_conditions.named_fields``, which does this over the + generator's own condition tree -- "``agent-notification-config.json``'s + conditional reads ``event_types`` and ``all_authorized_accounts``, and + naming them is the difference between a diagnostic and a citation". + + Recurses through the composition and conditional keywords, because that is + where the names live for the failure this serves: an ``allOf`` node's own + ``properties`` is usually empty and its arms carry everything. + """ + found: set[str] = set() + seen: set[int] = set() + + def walk(current: Any) -> None: + if isinstance(current, dict): + if id(current) in seen: + return + seen.add(id(current)) + properties = current.get("properties") + if isinstance(properties, dict): + found.update(str(name) for name in properties) + required = current.get("required") + if isinstance(required, list): + found.update(str(name) for name in required) + for keyword in ("allOf", "anyOf", "oneOf", "not", "if", "then", "else", "items"): + walk(current.get(keyword)) + dependencies = current.get("dependencies") or current.get("dependentRequired") + if isinstance(dependencies, dict): + found.update(str(name) for name in dependencies) + elif isinstance(current, list): + if id(current) in seen: + return + seen.add(id(current)) + for item in current: + walk(item) + + walk(node) + return tuple(sorted(found)) + + +def _safe_message(err: Any, keyword: str) -> str: + """Value-free description of a ``jsonschema`` failure. + + Never echoes the offending payload value — that would leak bearer + tokens, PII, or prompt-injection strings through the error envelope. + Keeps only the structural facts the caller needs to locate the + offending field and understand why the schema rejected it. + """ + enum_size: int | None = None + if keyword == "enum": + try: + enum_size = len(err.validator_value) + except TypeError: + enum_size = 0 + # A composition's names come off the SCHEMA, never the instance, so the + # oracle cites the same thing the compiled lane's context carries. + # + # The ARMS, not the enclosing node. ``err.schema`` for a root ``anyOf`` is + # the whole request schema, so walking it names all 31 of its properties -- + # noise, and the inverse of a citation. ``err.validator_value`` is the + # composition's own operand list, which is what the generator's + # ``named_fields`` walks too: for the create-request root it yields the six + # names that distinguish the arms. + fields: Any = UNSET + if keyword in _COMPOSITION_KEYWORDS: + fields = _declared_field_names(getattr(err, "validator_value", None)) or UNSET + return safe_message_for( + keyword, + required_message=str(err.message) if keyword == "required" else None, + bound=err.validator_value, + enum_size=enum_size, + format_name=err.validator_value, + fields=fields, + ) + + def _missing_required_key(err: Any) -> str | None: """Extract the missing property name from a ``required`` failure. diff --git a/tests/capture_root_documents.py b/tests/capture_root_documents.py new file mode 100644 index 000000000..107152304 --- /dev/null +++ b/tests/capture_root_documents.py @@ -0,0 +1,414 @@ +"""Record every document that reaches a root constraint, without touching the subject. + +An opt-in pytest plugin. It arms PEP 669 local monitoring events +(``sys.monitoring``) on the code objects the compiled-condition vocabulary +already carries and reads the first argument out of the monitored frame. +Nothing in ``adcp.types._conditions`` is rebound, so identity, ``__module__``, +``__name__``, signature and ``__all__`` are the ones an uninstrumented run +sees, and the artifact carries that as a re-measured verdict rather than a +claim (see ``instrument_report``). + +Observation rather than replacement, because a recording wrapper IS a change to +the module under test: the predecessor ``setattr``-ed one over all 18 primitives +and ``tests/test_conditions_vocabulary.py``, which derives the primitive list by +filtering ``vars(_conditions)`` on ``__module__``, then found none and failed for +a reason unrelated to the code under test. ``functools.wraps`` hides that symptom +and leaves a distinct function object, which a comparison of identities still +sees. salesagent-8qela.17. + +TWO CAPTURE POINTS, because the series enforces root content two ways and one +hook sees one of them: + +* a FLAT root carries a compiled ``model_validator`` that calls the vocabulary + with the model as its first argument -- PY_START on each primitive's code + object sees those. Measured at this base: 223 of 226 generated validators call + the vocabulary, and the three that do not compiled to constants + (``not (False or False or False)``) because their conjunct forbids fields the + model does not declare, so ``extra="forbid"`` refuses and no document reaches + them; +* a SPLIT root validates through the ``WrapValidator`` the emitter attaches to + its branches' union -- PY_START on the ``_wrap`` closure inside + ``name_the_combinator`` sees those. All 13 split roots pass through that one + closure, so one code object covers every one of them. + +A flat root's document is reconstructed with ``model_dump(exclude_unset=True)``: +the compiled validator runs in ``mode="after"``, so the raw input is gone by +then, and this is the document as the model understood it. A split root's +document is the raw input, which the union's wrapper still holds. + +KNOWN DEPENDENCY: the closure's name. ``name_the_combinator`` must contain +exactly one nested code object named ``_wrap``, and :func:`split_capture_point` +refuses at import if it does not. Depending on a production function's internal +name is the price of not putting a named observation hook in production, and a +loud local refusal is the cheaper half of that trade. + +LOAD IT WITH ``-p``, NEVER FROM ``conftest.py``. Auto-loading would make every +suite run an instrumented run, and an instrumented run is a different +configuration: a plain run differenced against one is a configuration delta, not +a code delta, which is the provenance problem the capture corpus's manifest +exists to solve. ``python -m pytest`` puts the invocation directory on +``sys.path``, which is what makes the module name importable at plugin-load +time:: + + ROOT_DOCUMENT_CAPTURE=/path/to/rootdocs.json \\ + python -m pytest -p tests.capture_root_documents <selection> + +A SPLIT RECORD'S ``names`` ARE READ AT ENTRY: all of a root's branch class names, +from the ``branches`` the wrapper closes over, rather than the one branch that +matched. Naming the matched branch needs the outcome, and the outcome needs +PY_UNWIND, which cannot be armed on one code object -- only globally, over every +unwinding frame in the process. Recording at entry also records a document that +satisfies NO branch, which leaves ``_wrap`` as a ``PydanticCustomError`` -- a +``ValueError``, not a ``ValidationError``. ``names`` is descriptive and is not +part of the dedup key, so a record present in two corpora carries the same +document in both either way. +""" + +from __future__ import annotations + +import json +import os +import sys +import types +from pathlib import Path +from typing import Any + +import pytest +from pydantic import BaseModel + +import adcp.types._conditions as conditions + +if not hasattr(sys, "monitoring"): # pragma: no cover - a refusal, not a branch + raise RuntimeError( + f"this instrument observes through PEP 669, which is CPython 3.12+, and " + f"{sys.version.split()[0]} has no sys.monitoring. The alternative is a mechanism " + "that rebinds the module under test, which is what this one replaced." + ) + +E = sys.monitoring.events + +#: Tool ids 0, 1, 2 and 5 are spoken for by name -- debugger, coverage, profiler, +#: optimizer -- and ``sys.settrace`` is built on the debugger id, so a tool that +#: must coexist with coverage takes one of the two unreserved ids. +_CANDIDATE_TOOL_IDS = (3, 4) + +#: The environment variable naming the artifact. There is no default: an unset +#: path means the operator did not say where the corpus goes, and a silent +#: fallback is how one lane's corpus overwrites another's. +_ARTIFACT_ENV = "ROOT_DOCUMENT_CAPTURE" + + +def split_capture_point() -> types.CodeType: + """The ``_wrap`` closure inside ``name_the_combinator``. + + Read off the primitive's own constants, so it is the object a running union + executes rather than a re-derivation that could name a different function. + Refuses unless there is exactly one: zero or several means the primitive has + been restructured and the split capture point is no longer identified, which + has to be found out here and not in a corpus missing every split root. + """ + candidates = [ + const + for const in conditions.name_the_combinator.__code__.co_consts + if isinstance(const, types.CodeType) and const.co_name == "_wrap" + ] + if len(candidates) != 1: + raise RuntimeError( + f"name_the_combinator carries {len(candidates)} nested '_wrap' code objects, " + "so the split capture point cannot be identified" + ) + return candidates[0] + + +def flat_capture_points() -> dict[types.CodeType, str]: + """Every public primitive the vocabulary defines, keyed by code object. + + Derived from the module rather than from ``__all__``, because a primitive can + be defined before it is exported and the predecessor iterated ``__all__`` and + so never saw ``members``. Refuses on an empty derivation, which would arm + nothing and report a clean empty corpus, and on an exported primitive the + derivation missed, which would be a hole in a capture point that looks whole. + """ + found = { + value.__code__: name + for name, value in vars(conditions).items() + if not name.startswith("_") + and isinstance(value, types.FunctionType) + and value.__module__ == conditions.__name__ + } + if not found: + raise RuntimeError( + f"no primitives found in {conditions.__name__}: the derivation would arm nothing" + ) + exported = { + name + for name in getattr(conditions, "__all__", ()) + if isinstance(getattr(conditions, name, None), types.FunctionType) + } + missing = exported - set(found.values()) + if missing: + raise RuntimeError(f"exported primitives the derivation missed: {sorted(missing)}") + return found + + +_WRAP_CODE = split_capture_point() +_PRIMITIVES = flat_capture_points() + +#: What the subject looked like before anything was armed, so the "nothing was +#: rebound" verdict is a comparison and not an assertion. Taken at import, which +#: is before any arming: ``install`` is called from ``pytest_configure``. +_SUBJECT_BEFORE = { + name: (id(value), getattr(value, "__module__", None), getattr(value, "__name__", None)) + for name, value in vars(conditions).items() + if isinstance(value, types.FunctionType) +} + +_SEEN: dict[str, dict[str, Any]] = {} +_OUT: Path | None = None +_TOOL_ID: int | None = None + +#: The item running right now, so a refused document names the test that built +#: it; without it a classifier reports a document and no way to reach the fixture. +_NODEID = "" + +#: Observation faults, reported in the artifact rather than raised: a callback +#: that raises propagates into the monitored frame and changes the run, which is +#: the one thing this instrument exists to avoid. A fault is counted and the pass +#: continues with a record of how blind it was. +_FRAME_MISMATCHES = 0 +_CALLBACK_FAULTS = 0 + +#: ``_record`` must not re-enter the callback. Nothing it calls reaches an armed +#: code object today -- serialization is not validation -- and this keeps that +#: from becoming silent recursion if it ever does. +_RECORDING = False + + +def _jsonable(value: Any) -> Any: + return json.loads(json.dumps(value, default=str)) + + +def _record(document: Any, source: str, names: list[str]) -> None: + if not isinstance(document, dict) or not document: + return + try: + payload = _jsonable(document) + key = f"{source}:{json.dumps(payload, sort_keys=True)}" + except (TypeError, ValueError): + return + _SEEN.setdefault( + key, + { + "source": source, + "names": sorted(set(names)), + "document": payload, + "nodeid": _NODEID, + }, + ) + + +def _on_start(code: types.CodeType, instruction_offset: int) -> None: # noqa: ARG001 + """A monitored frame has started, with its parameters already bound. + + ``sys._getframe(1)`` is that frame: the callback runs at the monitored + instruction, so the frame below it is the one that started. The identity + check keeps a wrong reading from being recorded as a document -- a mismatch + is counted into the artifact instead. + """ + global _FRAME_MISMATCHES, _CALLBACK_FAULTS, _RECORDING + if _RECORDING: + return + try: + frame = sys._getframe(1) + if frame.f_code is not code: + _FRAME_MISMATCHES += 1 + return + local = frame.f_locals + _RECORDING = True + try: + if code is _WRAP_CODE: + branches = local.get("branches") or () + _record( + local.get("value"), + "split", + [getattr(branch, "__name__", str(branch)) for branch in branches] + or [str(local.get("keyword"))], + ) + return + names = code.co_varnames[: code.co_argcount] + subject = local.get(names[0]) if names else None + if isinstance(subject, BaseModel): + _record( + subject.model_dump(exclude_unset=True, mode="json"), + "flat", + [type(subject).__name__], + ) + finally: + _RECORDING = False + except Exception: # noqa: BLE001 - an observation fault must not fail the test + _CALLBACK_FAULTS += 1 + _RECORDING = False + + +def install(out: Path) -> int: + """Arm both capture points and return the monitoring tool id. + + Separate from import, so importing this module instruments nothing: a test + that imports it to grade it would otherwise arm monitoring for the rest of + the session. + """ + global _OUT, _TOOL_ID + if _TOOL_ID is not None: + raise RuntimeError(f"already installed on tool id {_TOOL_ID}") + for tool_id in _CANDIDATE_TOOL_IDS: + if sys.monitoring.get_tool(tool_id) is None: + break + else: + raise RuntimeError( + "no unreserved sys.monitoring tool id is free: " + + ", ".join(f"{i}={sys.monitoring.get_tool(i)}" for i in _CANDIDATE_TOOL_IDS) + ) + sys.monitoring.use_tool_id(tool_id, "root-document-capture") + sys.monitoring.register_callback(tool_id, E.PY_START, _on_start) + for code in (*_PRIMITIVES, _WRAP_CODE): + sys.monitoring.set_local_events(tool_id, code, E.PY_START) + _OUT, _TOOL_ID = Path(out), tool_id + return tool_id + + +def uninstall() -> None: + """Disarm every capture point and release the tool id.""" + global _TOOL_ID + if _TOOL_ID is None: + return + for code in (*_PRIMITIVES, _WRAP_CODE): + sys.monitoring.set_local_events(_TOOL_ID, code, 0) + sys.monitoring.register_callback(_TOOL_ID, E.PY_START, None) + sys.monitoring.free_tool_id(_TOOL_ID) + _TOOL_ID = None + + +def records() -> list[dict[str, Any]]: + """The documents captured so far, deduped on source plus document.""" + return list(_SEEN.values()) + + +def _subject_changes() -> list[str]: + changed = [] + now = vars(conditions) + for name, before in _SUBJECT_BEFORE.items(): + value = now.get(name) + if value is None: + changed.append(f"{name}: gone") + continue + after = (id(value), getattr(value, "__module__", None), getattr(value, "__name__", None)) + if after != before: + changed.append(f"{name}: {before} -> {after}") + return changed + + +def instrument_report() -> dict[str, Any]: + """What the instrument did, and whether its subject is still intact. + + ``subject_unchanged`` compares identity as well as ``__module__`` and + ``__name__``, so a ``functools.wraps``-transparent rebind -- which restores + both names and leaves a different function object -- loses the claim. + """ + changed = _subject_changes() + return { + "mechanism": "sys.monitoring PY_START local events (PEP 669)", + "tool_id": _TOOL_ID, + "armed_primitives": sorted(_PRIMITIVES.values()), + "armed_split_point": f"{conditions.name_the_combinator.__qualname__}.<locals>._wrap", + "subject_unchanged": not changed, + "subject_changes": changed, + "frame_mismatches": _FRAME_MISMATCHES, + "callback_faults": _CALLBACK_FAULTS, + } + + +# ---- the artifact, written as the run proceeds ---------------------------- +# Not at session finish: a pass that is killed, times out, or dies on a +# contended box would then produce no file at all rather than a partial one. The +# run is dominated by test execution, so rewriting the records every 25 items +# costs nothing measurable. +_FLUSH_EVERY = 25 +_COLLECTED = 0 +_COMPLETED = 0 + + +def flush() -> None: + """Write the artifact. Refuses before :func:`install` has named a path.""" + if _OUT is None: + raise RuntimeError(f"no artifact path: {_ARTIFACT_ENV} was never read") + captured = records() + split = sum(1 for record in captured if record["source"] == "split") + _OUT.write_text( + json.dumps( + { + "complete": _COLLECTED > 0 and _COMPLETED >= _COLLECTED, + "items_collected": _COLLECTED, + "items_completed": _COMPLETED, + "instrument": instrument_report(), + "documents": captured, + "by_source": {"split": split, "flat": len(captured) - split}, + }, + indent=1, + ) + ) + + +def pytest_configure(config: Any) -> None: # noqa: ARG001 + destination = os.environ.get(_ARTIFACT_ENV) + if not destination: + # UsageError, not RuntimeError: pytest renders this one as a usage + # message, where an exception from pytest_configure arrives as + # INTERNALERROR and reads like a crash in the plugin. + raise pytest.UsageError( + f"{_ARTIFACT_ENV} is unset, so there is nowhere to write the corpus. " + "Name the artifact explicitly; a default path silently overwrites " + "another lane's capture." + ) + install(Path(destination)) + + +def pytest_collection_modifyitems( + session: Any, config: Any, items: list[Any] +) -> None: # noqa: ARG001 + global _COLLECTED + _COLLECTED = len(items) + flush() + + +def pytest_runtest_logstart(nodeid: str, location: Any) -> None: # noqa: ARG001 + global _NODEID + _NODEID = nodeid + + +def pytest_runtest_logfinish(nodeid: str, location: Any) -> None: # noqa: ARG001 + global _COMPLETED + _COMPLETED += 1 + if _COMPLETED % _FLUSH_EVERY == 0: + flush() + + +def pytest_sessionfinish(session: Any, exitstatus: int) -> None: # noqa: ARG001 + flush() + captured = records() + split = sum(1 for record in captured if record["source"] == "split") + report = instrument_report() + print( + f"\n[capture] {len(captured)} distinct documents " + f"({split} split-root, {len(captured) - split} flat-root) " + f"from {_COMPLETED} of {_COLLECTED} items -> {_OUT}" + ) + print( + f"[capture] subject unchanged: {report['subject_unchanged']}" + f"{'' if report['subject_unchanged'] else ' ' + str(report['subject_changes'])}" + f"; frame mismatches {report['frame_mismatches']}" + f"; callback faults {report['callback_faults']}" + ) + + +def pytest_unconfigure(config: Any) -> None: # noqa: ARG001 + uninstall() diff --git a/tests/conformance/_schema_parity.py b/tests/conformance/_schema_parity.py index 02f142c26..d2cc88965 100644 --- a/tests/conformance/_schema_parity.py +++ b/tests/conformance/_schema_parity.py @@ -601,11 +601,11 @@ def _is_numeric_string(value: Any) -> bool: and not isinstance(d.mutation, bool) and d.mutation in (0, 1) ), - # The model renders a ``oneOf``/``anyOf`` as a plain union whose arms - # allow extra properties, so a value that fits no arm's declared type is - # kept as an extra field on whichever arm validates. The schema evaluates + # The model renders a ``oneOf``/``anyOf`` as a plain union whose branches + # allow extra properties, so a value that fits no branch's declared type is + # kept as an extra field on whichever branch validates. The schema evaluates # the composition and refuses. - "union_arm_absorbs_a_type_error": lambda d: ( + "union_branch_absorbs_a_type_error": lambda d: ( d.direction == MODEL_ACCEPTS and d.keyword in ("oneOf", "anyOf") ), # ``CanonicalBoundaryModel`` refuses any payload carrying a legacy diff --git a/tests/conformance/reporting/_production_support.py b/tests/conformance/reporting/_production_support.py index d0db383bf..34a25e6dd 100644 --- a/tests/conformance/reporting/_production_support.py +++ b/tests/conformance/reporting/_production_support.py @@ -552,6 +552,11 @@ async def production_harness( "grain": "row", "primary_keys": ["row_id"], "canonicalization_id": key.canonicalization.canonicalization_id, + # allOf member 1 of core/reporting-delivery-offering.json requires the + # whole canonical-digest contract on a consumer_receipt offering, these + # two schema constants included. + "canonicalization_contract_version": "1.0", + "canonicalization_media_type": "application/vnd.adcp.reporting-canonicalization+json", "canonicalization_uri": key.canonicalization.canonicalization_uri, "canonicalization_sha256": key.canonicalization.canonicalization_sha256, } diff --git a/tests/conformance/reporting/test_reporting_core_lifecycle.py b/tests/conformance/reporting/test_reporting_core_lifecycle.py index 2dcfdb4d0..ae117a5c1 100644 --- a/tests/conformance/reporting/test_reporting_core_lifecycle.py +++ b/tests/conformance/reporting/test_reporting_core_lifecycle.py @@ -1517,7 +1517,7 @@ async def test_a_later_successful_read_supersedes_with_received( ledger, client, now=boundary + timedelta(hours=2), - obligations=[_obligation_wire(ledger, obligation)], + obligations=[_obligation_wire(ledger, obligation, revision)], readings={obligation.reporting_obligation_id: reading}, revisions={revision.reporting_revision_id: _revision_wire(revision, obligation)}, current_statuses=await _caller_statuses(ledger), @@ -1569,7 +1569,7 @@ async def test_a_failed_read_posts_unreadable_with_a_failure_code( ledger, client, now=period.expected_at + RECOVERY_WINDOW, - obligations=[_obligation_wire(ledger, obligation)], + obligations=[_obligation_wire(ledger, obligation, revision)], readings={ obligation.reporting_obligation_id: ReportingContentReading( reporting_revision_id=revision.reporting_revision_id, @@ -1622,7 +1622,7 @@ async def test_a_contract_violation_posts_content_mismatch_with_its_code( ledger, client, now=period.expected_at + RECOVERY_WINDOW, - obligations=[_obligation_wire(ledger, obligation)], + obligations=[_obligation_wire(ledger, obligation, revision)], # The pinned definition promises `conversions`; the revision does not # carry it, which is a contract fact -- not a count dispute. definition=ReportingPinnedDefinition(metric_names=("impressions", "spend", "conversions")), @@ -1700,11 +1700,22 @@ async def test_a_rejected_statement_is_surfaced_not_raised( outcome.raise_for_failures() -def _obligation_wire(ledger: PgReportingLedgerStore, obligation: Any) -> Any: +def _obligation_wire(ledger: PgReportingLedgerStore, obligation: Any, revision: Any = None) -> Any: """The seller's own obligation, as the wire model the planner consumes. Round-tripped through ``get_reporting_status`` rather than hand-built, so the planner is fed exactly what a buyer would read. + + The obligation's own revision is passed rather than an empty sequence, and + it has to be: ``_obligation_to_wire`` derives ``revision_count`` as + ``len(revisions)``, and ``core/reporting-obligation.json`` forbids a + published or healthy obligation that reports none -- + ``allOf/1/then/properties/revision_count/minimum`` (health in + {healthy, complete}) and ``allOf/5/then/properties/revision_count/minimum`` + (production_status published) both require at least 1. Validating the old + ``revisions=()`` payload against the pin with ``jsonschema`` raises at + exactly those two paths, so the empty sequence contradicted the + ``health="healthy", production_status="published"`` this helper declares. """ del ledger from adcp.reporting.ledger.status import _obligation_to_wire @@ -1712,7 +1723,7 @@ def _obligation_wire(ledger: PgReportingLedgerStore, obligation: Any) -> Any: payload = _obligation_to_wire( obligation, - revisions=(), + revisions=() if revision is None else (revision,), health="healthy", production_status="published", issues=(), diff --git a/tests/conformance/reporting/test_reporting_currency.py b/tests/conformance/reporting/test_reporting_currency.py index 77a85f080..427261bb6 100644 --- a/tests/conformance/reporting/test_reporting_currency.py +++ b/tests/conformance/reporting/test_reporting_currency.py @@ -6,7 +6,7 @@ from collections.abc import AsyncIterator, Callable from dataclasses import replace from datetime import datetime, timedelta, timezone -from decimal import Decimal, localcontext +from decimal import localcontext from typing import Any import pytest @@ -864,16 +864,42 @@ def must_not_resolve( ("media_buy", "CURRENCY_MISMATCH"), ("package", "CURRENCY_MISMATCH"), ("mixed_packages", "MIXED_CURRENCY_SCOPE"), - ("reporting_rows", "CURRENCY_MISMATCH"), - ("matching_eur", None), ], ) async def test_delivery_response_currency_is_checked_before_flattening( store: ReportingLedgerStore, monkeypatch: pytest.MonkeyPatch, shape: str, - expected_error: str | None, + expected_error: str, ) -> None: + """A denomination anywhere in the response contradicts the frozen request. + + Every shape here states its currency on the delivery response itself, which + is what the name means and what ``_delivery_rows`` reads before flattening. + + TWO SHAPES WERE REMOVED AND MUST NOT COME BACK AS THEY WERE. + ``reporting_rows`` and ``matching_eur`` put canonical rows on the response to + get exact decimal money onto a surface whose own metrics are floats. The pin + forbids the document they built: ``get-media-buy-delivery-response.json`` + requires ``reporting_revision``, ``reporting_revision_binding`` and + ``pagination`` as soon as ``reporting_rows`` is present, and the field is for + "one page of authoritative logical rows for an exact reporting_revision_id + read" -- a different operation from this fetch. The model refused both + documents, the refusal reached ``_unclassified`` as PROVIDER_TRANSIENT, and + the two cases had never once executed the currency logic they named. + + Supplying the missing fields does not rescue them: the revision's + ``control_totals`` values are integer strings (``^-?(?:0|[1-9][0-9]*)$``), so + the ``"0.30"`` spend those two cases were about cannot be stated there at + all. + + The exact-decimal concern is covered, twice, through the surface the other + twelve ``money_rows`` call sites in this file use -- + ``test_sealed_source_retry_after_commit_failure_keeps_currency`` and + ``test_inline_money_is_frozen_before_staging_awaits`` both assert a + ``"0.30"`` spend control total from ``money_rows``. + """ + def fetch(request: ReportingSourceSliceRequestV1) -> GetMediaBuyDeliveryResponse: delivery: dict[str, Any] = { "media_buy_id": "mb_eur", @@ -885,12 +911,8 @@ def fetch(request: ReportingSourceSliceRequestV1) -> GetMediaBuyDeliveryResponse "reporting_period": {"start": START, "end": END}, "media_buy_deliveries": [delivery], } - if shape in {"response", "reporting_rows", "matching_eur"}: - payload["currency"] = "EUR" if shape == "matching_eur" else "USD" - if shape in {"reporting_rows", "matching_eur"}: - # Core's canonical row format uses exact decimal strings; the - # ordinary delivery surface models its legacy metrics as floats. - payload["reporting_rows"] = money_rows(request) + if shape == "response": + payload["currency"] = "USD" if shape == "media_buy": delivery["currency"] = "USD" if shape in {"package", "mixed_packages"}: @@ -913,22 +935,16 @@ def fetch(request: ReportingSourceSliceRequestV1) -> GetMediaBuyDeliveryResponse producer = producer_for(store, source, staging, currency="EUR") config = configuration() (obligation,) = await close_admitted_periods(producer, config) - if expected_error is None: - revision = await producer.acquire_obligation(config, obligation) - assert revision is not None - assert Decimal(dict(revision.control_totals)["spend"]) == Decimal("0.30") - assert obligation.currency == "EUR" - else: - def must_not_aggregate(*args: Any) -> None: - raise AssertionError("delivery-response currency was discarded before aggregation") + def must_not_aggregate(*args: Any) -> None: + raise AssertionError("delivery-response currency was discarded before aggregation") - monkeypatch.setattr("adcp.reporting.inline_source._control_totals", must_not_aggregate) - request = producer._build_slice(config, obligation, SNAPSHOT_OFFERING_ID, now=NOW) - result = await source.execute(request, cancel=asyncio.Event()) - assert result.error is not None and result.error.code == "INTEGRITY_FAILED" - assert result.error.retry == "terminal" - assert expected_error in result.error.safe_message + monkeypatch.setattr("adcp.reporting.inline_source._control_totals", must_not_aggregate) + request = producer._build_slice(config, obligation, SNAPSHOT_OFFERING_ID, now=NOW) + result = await source.execute(request, cancel=asyncio.Event()) + assert result.error is not None and result.error.code == "INTEGRITY_FAILED" + assert result.error.retry == "terminal" + assert expected_error in result.error.safe_message async def test_inline_money_is_frozen_before_staging_awaits( diff --git a/tests/conformance/reporting/test_reporting_notification_packaging.py b/tests/conformance/reporting/test_reporting_notification_packaging.py index 615786fb5..53addde9d 100644 --- a/tests/conformance/reporting/test_reporting_notification_packaging.py +++ b/tests/conformance/reporting/test_reporting_notification_packaging.py @@ -86,6 +86,58 @@ def redacted_stage_stderr(stderr): ) +def _signal_owned_group(process, sent_signal): + """Signal this task's own child process group, tolerating a group already gone. + + ``start_new_session=True`` makes the child both session and group leader, so + its pid is its pgid. "The group is already gone" reaches us as TWO different + errnos depending on whether the leader has been reaped. Measured here, one + child per row, ``ps`` consulted without reaping: + + live leader ps Ss killpg OK + zombie leader, group empty ps Z killpg PermissionError [errno 1] + reaped leader, group empty ps gone killpg ProcessLookupError [errno 3] + zombie leader + live grandchild ps Z killpg OK + reaped leader + live grandchild ps gone killpg OK + + So EPERM arises in exactly one state: the group's only remaining member is the + unreaped zombie leader -- precisely when there is nothing left to signal. It + means the same thing ESRCH means, and both are tolerated. + + REAPING FIRST WAS TRIED AND DOES NOT WORK HERE, which is worth recording + because it is the more obvious fix. ``Popen.poll()`` is advisory: it takes + ``_waitpid_lock`` with ``blocking=False`` and returns ``None`` without + reaping if anything else holds it. The progress monitor polls the same child + from its own thread, so on the failing path ``poll()`` returned ``None`` and + ``returncode`` stayed ``None`` against a child ``ps`` reported as ``Z``. A + reap that cannot be guaranteed cannot replace the guard. + + ``os.getpgid`` is not a usable precondition either: it raised ESRCH in four of + the five states above, including the two where ``killpg`` SUCCEEDS because a + grandchild is alive. It answers a question about the process, not the group, + so gating the signal on it would leak live grandchildren. + + Both errnos are caught rather than switching on platform: POSIX specifies + ESRCH for no-such-group, Darwin returns EPERM for a zombie leader, and Linux + is untested here. + + THE PRECONDITION IS THE CALLER'S, and this guard cannot check it. Tolerating + EPERM is only correct for a group this process created and has not reaped; + passing any other pid makes it swallow a real permission error, which probing + it with ``pid=1`` confirms it does. That is why this helper is private and why + both call sites hand it the ``Popen`` built above with + ``start_new_session=True``. It is not a general-purpose killpg wrapper, and + widening it to one would need a way to prove ownership that this module does + not have -- ``poll()`` cannot, for the reason above. + """ + try: + os.killpg(process.pid, sent_signal) + except (ProcessLookupError, PermissionError): + # The owned group already exited; the caller still drains its pipes and + # reaps the child below. + pass + + def run_step(command, *, label, cwd, value=None, timeout=120, progress=None): started = time.monotonic() environment = {key: item for key, item in os.environ.items() if key != "PYTHONPATH"} @@ -128,11 +180,7 @@ def run_step(command, *, label, cwd, value=None, timeout=120, progress=None): # Give only this task-owned process group a bounded graceful shutdown, # then escalate. A timeout is a failing gate, never an implicit retry. cleanup = "terminated" - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - # The owned group already exited; still drain its pipes and reap the child below. - pass + _signal_owned_group(process, signal.SIGTERM) if progress is not None: progress.signal_owned(signal.SIGTERM) try: @@ -142,10 +190,7 @@ def run_step(command, *, label, cwd, value=None, timeout=120, progress=None): if progress is not None: progress.signal_owned(signal.SIGKILL) if progress is None or not progress.supervised: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass + _signal_owned_group(process, signal.SIGKILL) try: stdout, stderr = process.communicate(timeout=5) except subprocess.TimeoutExpired as error: diff --git a/tests/conformance/reporting/test_reporting_reconciliation_store.py b/tests/conformance/reporting/test_reporting_reconciliation_store.py index 716c15ddf..5e104ebba 100644 --- a/tests/conformance/reporting/test_reporting_reconciliation_store.py +++ b/tests/conformance/reporting/test_reporting_reconciliation_store.py @@ -28,7 +28,8 @@ revision_to_wire, ) from adcp.types import ( - GetReportingStatusResponse, + ReportingAdjustment, + ReportingAdjustmentReceipt, ReportingMaterialization, ReportingReceipt, ReportingRevision, @@ -469,7 +470,10 @@ async def test_adjustment_digest_finality_and_independent_receipt_chain( expected = wire.pop("canonical_adjustment_sha256") assert expected == hashlib.sha256(canonical_json_utf8_v1(wire)).hexdigest() wire["canonical_adjustment_sha256"] = expected - GetReportingStatusResponse.model_validate({"status": "completed", "adjustments": [wire]}) + # The adjustment's own wire shape is the subject. A status response cannot stand + # in for it: media-buy/get-reporting-status-response.json's root names the field + # groups a lookup carries, and status plus one array is not one of them. + ReportingAdjustment.model_validate(wire) record = ReportingAdjustmentReceiptRecord( s.attempt.scope, "adjustment-receipt-0001", @@ -482,9 +486,7 @@ async def test_adjustment_digest_finality_and_independent_receipt_chain( # The schema defines independent evidence; it does not require acceptance # of the official receipt first. Completion still needs both receipts. accepted, _ = await store.record_adjustment_receipt(record) - GetReportingStatusResponse.model_validate( - {"status": "completed", "adjustment_receipts": [receipt_to_wire(accepted)]} - ) + ReportingAdjustmentReceipt.model_validate(receipt_to_wire(accepted)) assert await store.record_adjustment_receipt(record) == (accepted, False) with pytest.raises(LedgerConflictError) as error: await store.record_adjustment_receipt( diff --git a/tests/conformance/reporting/test_reporting_reconciliation_transitions.py b/tests/conformance/reporting/test_reporting_reconciliation_transitions.py index a59649a56..95e102192 100644 --- a/tests/conformance/reporting/test_reporting_reconciliation_transitions.py +++ b/tests/conformance/reporting/test_reporting_reconciliation_transitions.py @@ -69,6 +69,9 @@ async def test_generated_projections_are_accepted_by_buyer_reconciler( accepted_receipt_count=1, adjustment_receipt_count=0, accepted_adjustment_receipt_count=0, + # Member 7 of core/reporting-obligation.json requires this third count on a + # complete consumer_receipt obligation, alongside the two receipt counts. + pending_adjustment_count=0, resource_retained_until=s.delivery.resource_retained_until.isoformat(), ) result["revisions"] = [revision_to_wire(s.revision, obligation=s.obligation)] diff --git a/tests/conformance/reporting/test_reporting_status_capabilities.py b/tests/conformance/reporting/test_reporting_status_capabilities.py index 6a2f92f4d..94c48ce5a 100644 --- a/tests/conformance/reporting/test_reporting_status_capabilities.py +++ b/tests/conformance/reporting/test_reporting_status_capabilities.py @@ -267,6 +267,27 @@ async def test_authoritative_task_is_callable_through_mcp_and_a2a_with_authentic assert auditor["issues"] == [] +#: What the managed and reconciled tiers oblige, stated once. +_MANAGED_TIER = {"resource_retention_days": 90, "authorization_revocation_seconds": 3600} +_METHOD = { + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "provider": {"domain": "aws.amazon.com"}, + "format": "jsonl", + } +} +_CANONICALIZATION = { + "canonicalization_id": "rows-v1", + "canonicalization_contract_version": "1.0", + "canonicalization_media_type": "application/vnd.adcp.reporting-canonicalization+json", + "canonicalization_uri": "https://seller.example/canonicalization/rows-v1.json", + "canonicalization_sha256": "c" * 64, +} + + @pytest.mark.parametrize("tier", ["core", "managed", "reconciled"]) def test_raw_generated_capability_dumps_omit_unset_task_notification_and_false_flags(tier): model = ReportingDeliveryCapabilities.model_validate( @@ -284,8 +305,11 @@ def test_raw_generated_capability_dumps_omit_unset_task_notification_and_false_f "version": "1", "schema_uri": "https://seller.example/schema", "schema_sha256": "b" * 64, + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", "grain": "media_buy", "primary_keys": ["media_buy_id"], + **(_CANONICALIZATION if tier == "reconciled" else {}), }, "schedule": { "period_duration": "PT1H", @@ -293,19 +317,32 @@ def test_raw_generated_capability_dumps_omit_unset_task_notification_and_false_f "delivery_sla": "PT1H", }, "supported_finality": ["snapshot"], - "reconciliation_mode": "delivery_only", + "reconciliation_mode": ( + "consumer_receipt" if tier == "reconciled" else "delivery_only" + ), + **(_METHOD if tier in {"managed", "reconciled"} else {}), } ], "automated_recovery_window_seconds": 0, "status_retention_days": 7, + # allOf member 3 ties the two retention windows and a method-bearing + # offering to the managed tier, and member 4 ties a receipt_task and a + # consumer_receipt offering to the reconciled one. + **(_MANAGED_TIER if tier in {"managed", "reconciled"} else {}), **({"managed_delivery": True} if tier in {"managed", "reconciled"} else {}), - **({"reconciled_billing": True} if tier == "reconciled" else {}), + **( + {"reconciled_billing": True, "receipt_task": "sync_reporting_receipts"} + if tier == "reconciled" + else {} + ), } ) fields = set(model.model_fields_set) before = model.status_notification + # Only the tasks the tier itself requires are set, so only those may appear. + required_tasks = {"receipt_task"} if tier == "reconciled" else set() for raw in (model.model_dump(mode="json"), json.loads(model.model_dump_json())): - assert not any(k.endswith(("_task", "_notification")) for k in raw) + assert {k for k in raw if k.endswith(("_task", "_notification"))} == required_tasks assert "supports_webhook_activity" not in raw and "reliable_reporting_version" not in raw assert ("managed_delivery" in raw) is (tier != "core") assert ("reconciled_billing" in raw) is (tier == "reconciled") diff --git a/tests/conformance/test_branch_constraint_inheritance.py b/tests/conformance/test_branch_constraint_inheritance.py new file mode 100644 index 000000000..c541c0c0b --- /dev/null +++ b/tests/conformance/test_branch_constraint_inheritance.py @@ -0,0 +1,445 @@ +"""A split root's branch constraint survives subclassing. + +THE DEFECT THIS EXISTS FOR. A root whose schema offers a choice between branches +is rendered as a shared base plus one class per branch, and the refusal for "this +document satisfies no branch" lives in the base's CORE SCHEMA -- the union of the +branches plus the wrapping validator that names the combinator. A subclass is +handed its own flat core schema instead, so it never consults that union and +accepts what the root refuses. Measured before the fix, one bare +``class Sub(Root): pass`` per root: + + roots that refuse by a combinator type 12 + subclasses that still refuse 1 <- and that one for the wrong reason + LOST 11 + +The single apparent survivor was ``verify-brand-claim-request``, whose four +branches all require the same pair of keys: "at least one branch matches" is then +equivalent to "those keys are present", so ordinary field requiredness refuses and +nothing about the combinator is inherited. That is why this test grades the ERROR +TYPE and not the fact of refusal -- a refusal of type ``missing`` is evidence of +nothing (salesagent-8qela.76). + +WHY IT MATTERS. ``CanonicalBoundaryModel``'s own docstring says inheritance is the +point of that layer, and every adopter subclass is in the same position. A lost +constraint produces a MISSING failure -- the model accepts what the pin refuses -- +so it is invisible to a regression count built from pass-to-fail transitions, to a +green suite, and to the parity corpus, which passes with a known defect re-armed. +Nothing would have caught this, which is why the guard is part of the fix. + +The population is DERIVED from the mixin's own marker, so a root added by a schema +bump is graded without editing this file, and it is asserted non-empty AND to +contain the known case before anything is compared. +""" + +from __future__ import annotations + +import ast +import importlib +import inspect +import json +import pkgutil +from pathlib import Path +from typing import Any + +import pytest +from pydantic import BaseModel, TypeAdapter, ValidationError + +import adcp.types.domains as domains +from adcp.validation.version import resolve_bundle_key +from scripts.post_generate_fixes import flat_root_shape +from scripts.schema_constraints import bundle_document_resolver, root_required_names +from tests.conformance import _schema_parity as parity + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_BUNDLE_KEY = resolve_bundle_key((_REPO_ROOT / "src" / "adcp" / "ADCP_VERSION").read_text().strip()) +_SCHEMA_DIR = _REPO_ROOT / "schemas" / "cache" / _BUNDLE_KEY + +#: The error types a combinator refusal carries. A refusal of any other type -- +#: ``missing`` above all -- says nothing about whether the constraint was +#: inherited, which is the distinction that turned an apparent survivor into an +#: artifact. +_COMBINATOR_TYPES = frozenset({"oneOf", "anyOf", "allOf"}) + +#: Measured at the 3.2.1 pin: 12 split roots carry a branch combinator and a +#: document satisfying no branch can be derived for each. A floor, because the +#: bundle grows between AdCP releases, and the population assertion this file +#: needs: an empty derivation would compare nothing and pass. +_MIN_SPLIT_ROOTS = 8 + + +def _split_roots() -> list[tuple[str, type[BaseModel]]]: + """Every generated class that IS a split root, by the mixin's own marker. + + ``BranchDispatchMixin.__get_pydantic_core_schema__`` decides whether a class + is the root or one of its branches by ``"_branch_models" in cls.__dict__``. + Reading the same marker means this population cannot drift from the thing it + grades: a class the mixin treats as a root is a class this test grades. + """ + found: dict[str, type[BaseModel]] = {} + for info in pkgutil.walk_packages(domains.__path__, domains.__name__ + "."): + try: + module = importlib.import_module(info.name) + except Exception: # noqa: BLE001 - an unimportable module is its own test's problem + continue + for name in dir(module): + obj = getattr(module, name) + if ( + isinstance(obj, type) + and issubclass(obj, BaseModel) + and "_branch_models" in obj.__dict__ + ): + found.setdefault(f"{info.name}.{name}", obj) + return sorted(found.items()) + + +def _refusal_types(model: type[BaseModel], document: Any) -> frozenset[str] | None: + """The error types ``model`` refuses ``document`` with, or ``None`` if it accepts.""" + try: + model.model_validate(document) + except ValidationError as error: + return frozenset(issue["type"] for issue in error.errors()) + return None + + +def _document_satisfying_no_branch(model: type[BaseModel]) -> Any | None: + """A document the ROOT refuses by a combinator, or ``None`` if none is found. + + The document must ISOLATE the combinator: the root's refusal has to be a + combinator type and nothing else. ``{}`` is the tempting probe and the wrong + one -- it also omits the root's genuinely required fields, so a subclass + refuses it by ``missing`` and the verdict says nothing about the constraint. + This helper first asked ``{}`` and reported four roots as broken whose fix + demonstrably works; the document was choosing the answer. + + So: take a schema-valid document from the repo's own synthesizer, strip only + the keys the branches select by, and accept it only if the root then refuses + by a combinator ALONE. Finding nothing is a skip, not a pass: a root this + helper cannot reach is ungraded rather than clean. + + "The keys the branches select by" is the branches' required UNION MINUS WHAT + THE ROOT REQUIRES. Without that subtraction this helper reproduced the very + ``{}`` its docstring warns against: ``list-account-changes-response``'s root + requires ``status`` through its ``allOf`` of ``core/protocol-envelope``, every + branch requires it too, so stripping the bare union emptied the document and + the subclass refused by ``missing`` (salesagent-8qela.137). + + A candidate also has to carry values the base's own FIELDS accept, or the + subclass refuses by ``enum`` and the keyword is again unreachable for an + unrelated reason. That filter is per field and says nothing about the + combinator, which is what this file asserts -- but it does mean a base that + over-narrows a field has the probe route around the narrowing rather than + report it (salesagent-8qela.138). + """ + candidates: list[Any] = [] + relative = _schema_for(model) + if relative is not None: + schema = json.loads((_SCHEMA_DIR / relative).read_text()) + resolver = parity._make_ref_resolver( + parity._ensure_state(None), _SCHEMA_DIR / relative, schema + ) + selectors: set[str] = set() + for keyword in ("oneOf", "anyOf"): + for branch in schema.get(keyword) or []: + if isinstance(branch, dict): + selectors.update(branch.get("required") or []) + selectors -= set(root_required_names(schema, bundle_document_resolver(_SCHEMA_DIR))) + for instance in parity.instances(schema, resolver, 0, 64): + if isinstance(instance, dict): + candidates.append({k: v for k, v in instance.items() if k not in selectors}) + for candidate in candidates: + if not _fields_accept_every_value(model, candidate): + continue + types = _refusal_types(model, candidate) + if types is not None and types <= _COMBINATOR_TYPES and types: + return candidate + return None + + +def _fields_accept_every_value(model: type[BaseModel], document: dict[str, Any]) -> bool: + """Whether each of ``document``'s values is accepted by its own field's type. + + Per field and nothing more: the question is whether this document has a fault + OTHER than satisfying no branch, and the whole document cannot be asked -- + a split root validates against the union of its branches and so answers + ``oneOf`` for every value, which is the verdict this file is here to grade. + """ + for name, value in document.items(): + field = model.model_fields.get(name) + if field is None: + continue + try: + TypeAdapter(field.annotation).validate_python(value) + except ValidationError: + return False + return True + + +def _schema_for(model: type[BaseModel]) -> Path | None: + """The bundled schema a generated module came from, by its module path.""" + parts = model.__module__.split(".") + if "domains" not in parts: + return None + relative = Path(*parts[parts.index("domains") + 1 :]).with_suffix(".json") + candidate = Path(str(relative).replace("_", "-")) + return candidate if (_SCHEMA_DIR / candidate).is_file() else None + + +def _generator_can_compile_a_check(model: type[BaseModel]) -> bool: + """Whether the GENERATOR can compile a field-group check for this root. + + Asks ``flat_root_shape`` -- the generator's own predicate -- rather than + looking at what was emitted. That distinction is the whole point: an + exemption keyed on "no validator is present" would skip exactly the roots + that lack one, so deleting an emitted validator would turn this test green by + skipping instead of red by failing. Keyed on the SHAPE, a root the generator + could have served and did not is a FAILURE, and only a root it structurally + cannot serve is skipped. + + ``None`` means the root has no single flat shape to read fields from -- for + the five response roots here, the branches genuinely are the types + (salesagent-8qela.81). + """ + relative = _schema_for(model) + if relative is None: + return False + schema = json.loads((_SCHEMA_DIR / relative).read_text()) + return flat_root_shape(schema, relative) is not None + + +def _carries_compiled_check(model: type[BaseModel]) -> bool: + """Whether the root CLASS itself defines the compiled field-group validator. + + Class-scoped, because the split copies that validator onto every BRANCH: a + module-wide search answers a different question, and asking the module rather + than the class is what made the first version of this fix a silent no-op. + """ + try: + source = Path(inspect.getsourcefile(model) or "").read_text() + except (OSError, TypeError): + return False + return any( + isinstance(node, ast.ClassDef) + and node.name == model.__name__ + and any( + isinstance(child, ast.FunctionDef) and child.name == "_require_schema_required_group" + for child in node.body + ) + for node in ast.parse(source).body + ) + + +def _branch_keys_the_schema_root_does_not_require(model: type[BaseModel]) -> set[str]: + """Keys every branch requires and the SCHEMA ROOT does not — the schema side. + + This is a fact about the pinned schema alone, with no reference to what was + generated, and it is what makes the guard below falsifiable: if no root in + the bundle has this shape then there is nothing for an over-requirement to be + measured against, and a green result would mean the derivation matched + nothing rather than that the generator is right. + + What the root requires comes from ``root_required_names``, the same owner the + branch emitter relaxes against, so the guard and the generator cannot hold + two answers. This read ``schema["required"]`` once and that is NOT what a + root requires: a root ``allOf: [{"$ref": ...}]`` is inheritance, and + ``account/list-account-changes-response`` declares none of its own while + ``allOf``-ing ``core/protocol-envelope``, which requires ``status``. Asked + against jsonschema, a document omitting ``status`` fails with ``required``, + so the generated class requiring it is correct and this helper reported it as + a defect (salesagent-8qela.137). + """ + relative = _schema_for(model) + if relative is None: + return set() + schema = json.loads((_SCHEMA_DIR / relative).read_text()) + root_required = set(root_required_names(schema, bundle_document_resolver(_SCHEMA_DIR))) + shared_across_branches: set[str] = set() + for keyword in ("oneOf", "anyOf"): + branches = [b for b in schema.get(keyword) or [] if isinstance(b, dict)] + if not branches: + continue + shared_across_branches |= set.intersection( + *(set(b.get("required") or []) for b in branches) + ) + return shared_across_branches - root_required + + +def _over_required_keys(model: type[BaseModel]) -> list[str]: + """Of those keys, the ones the generated CLASS marks required anyway. + + When every branch requires the same key the generator merges it onto the root + carrying that requiredness, so the root over-requires exactly the keys its + branches select by -- salesagent-8qela.80. Computed from the pinned schema so + what it reports is a measurement rather than a list. + """ + return sorted( + key + for key in _branch_keys_the_schema_root_does_not_require(model) + if (field := model.model_fields.get(key)) is not None and field.is_required() + ) + + +def _root_over_requires_a_branch_key(model: type[BaseModel]) -> bool: + """Whether the generated root requires a key its SCHEMA root does not. + + Used as an EXEMPTION by the subclass-refusal test below, because such a root + refuses a document omitting the key by ordinary field requiredness and never + reaches the after-validator -- which says nothing about whether the + combinator was inherited, this file's actual subject. The exemption + disappears on its own when salesagent-8qela.80 is fixed, so no list goes + stale. + """ + return bool(_over_required_keys(model)) + + +_POPULATION = _split_roots() + + +def test_the_split_root_population_is_derived_and_not_empty() -> None: + """The marker resolves, so the test below grades something.""" + assert len(_POPULATION) >= _MIN_SPLIT_ROOTS, ( + f"only {len(_POPULATION)} split roots found, expected at least {_MIN_SPLIT_ROOTS} — " + "the `_branch_models` marker has moved and this file no longer grades anything" + ) + assert any(name.endswith(".CreativeManifest") for name, _ in _POPULATION), ( + "CreativeManifest is the known case this guard was written for and it is absent " + f"from the derived population: {[n for n, _ in _POPULATION]}" + ) + + +@pytest.mark.parametrize(("name", "root"), _POPULATION, ids=[name for name, _ in _POPULATION]) +def test_a_subclass_still_refuses_what_its_split_root_refuses( + name: str, root: type[BaseModel] +) -> None: + if not _generator_can_compile_a_check(root): + # No merged shape, so there are no root FIELDS for a compiled field-group + # check to read: the branches genuinely ARE the types, which is one of the + # reasons ``flat_root_shape`` names for declining a root. The union + # wrapper is the only mechanism available for these, and a subclass loses + # it with no compiled form to fall back on (salesagent-8qela.81). + # Derived from the pin, not listed. + pytest.skip( + f"{name}: the schema root declares no properties, so no compiled " + "field-group check is expressible (salesagent-8qela.81)" + ) + + document = _document_satisfying_no_branch(root) + if document is None: + pytest.skip(f"{name}: no document found that the root refuses by a combinator") + + root_types = _refusal_types(root, document) + assert root_types is not None and root_types & _COMBINATOR_TYPES # the helper's contract + + subclass = type(root.__name__ + "Subclass", (root,), {}) + subclass_types = _refusal_types(subclass, document) + assert subclass_types is not None, ( + f"{name}: a bare subclass ACCEPTS a document its root refuses by " + f"{sorted(root_types & _COMBINATOR_TYPES)}. The branch constraint lives in the " + "root's core schema and a subclass is handed its own, so it consults no union. " + "Emit the compiled constraint on the shared base (salesagent-8qela.76)." + ) + if _root_over_requires_a_branch_key(root): + # The keyword cannot survive here, and the reason is a DIFFERENT defect: + # the generated root requires a key its schema root does not, so a + # document omitting it fails FIELD validation and no after-validator + # runs (salesagent-8qela.80). The refusal above is still asserted -- + # what cannot be asserted is which keyword carries it. Derived from the + # pin rather than listed, so this exemption disappears by itself when + # that bead is fixed and no allowlist goes stale. + return + assert subclass_types & _COMBINATOR_TYPES, ( + f"{name}: a bare subclass refuses by {sorted(subclass_types)} rather than by the " + f"combinator its root uses ({sorted(root_types & _COMBINATOR_TYPES)}). A refusal " + "for another reason — field requiredness above all — is not the constraint being " + "inherited, and a consumer reading the error type to learn WHY gets a different " + "answer than the root gives." + ) + + +#: Measured after the fix at the 3.2.1 pin: 13 of the 18 split roots carry the +#: compiled check; the five that do not declare no merged shape of their own +#: (salesagent-8qela.81). A floor, because the skip above would otherwise be +#: tautological -- it skips exactly the roots without a compiled check, so an +#: emitter that stopped emitting would turn every case into a pass. +_MIN_ROOTS_CARRYING_THE_COMPILED_CHECK = 11 + + +def test_the_compiled_check_is_emitted_widely() -> None: + """The emission still reaches most split roots, so the skip above stays honest.""" + carrying = [name for name, root in _POPULATION if _carries_compiled_check(root)] + assert len(carrying) >= _MIN_ROOTS_CARRYING_THE_COMPILED_CHECK, ( + f"only {len(carrying)} of {len(_POPULATION)} split roots carry the compiled " + f"field-group check, expected at least {_MIN_ROOTS_CARRYING_THE_COMPILED_CHECK} — " + "the emission in compose_branch_module has stopped reaching them, and the " + "inheritance test above would SKIP rather than fail" + ) + + +def test_a_split_root_requires_only_what_its_schema_root_requires() -> None: + """The merged root must not carry its branches' requiredness. + + THE DEFECT. The generator merges the branches' properties onto the root class + and carries their requiredness with them, so a root requires exactly the keys + its branches SELECT BY while its schema root requires none of them + (salesagent-8qela.80). Two consequences, and the second is why this is a + precondition rather than a tidy-up: + + * a document omitting such a key fails FIELD validation, so the root's + after-validator never runs and the combinator refusal is masked -- which is + the exemption ``_root_over_requires_a_branch_key`` grants above, and the + reason the test it exempts cannot grade those roots at all; + * "which keys is this document missing that the ROOT requires" stops being + answerable, because the class's own requiredness no longer matches the + schema's. salesagent-8qela.116 needs exactly that question answered, and an + intersection over the branches' errors cannot answer it while this stands: + measured against each root's pinned ``required`` list, that derivation + agrees on 8 of 18 roots, and TWO of its disagreements are this defect. + + THE POPULATION IS ASSERTED ON THE SCHEMA SIDE, which is what keeps a green + result meaningful. ``_branch_keys_the_schema_root_does_not_require`` reads the + pin and nothing else, so it reports the same keys before and after any + generator change. If it ever returns nothing for every root, this test is + comparing an empty set and passes while grading nothing -- so that is checked + first and fails loudly instead. + + ``list-account-changes-response`` IS NOT ONE OF THE VIOLATORS and must not be + restored as one. Its root requires ``status`` through its ``allOf`` of + ``core/protocol-envelope``, so the generated root requiring it is CORRECT. + This guard asserted it as a defect for as long as its schema side read + ``schema["required"]`` alone (salesagent-8qela.137) -- which meant the guard + could only be satisfied by relaxing a field the pin requires. It is still + GRADED, which is the assertion below: the probe must keep reaching it and + keep finding nothing, because "absent from the violator set" and "absent from + the population" are different facts and only the first is wanted. + """ + shape = { + name: sorted(_branch_keys_the_schema_root_does_not_require(root)) + for name, root in _POPULATION + } + with_shape = {name: keys for name, keys in shape.items() if keys} + assert with_shape, ( + "no split root in the pinned bundle has a key every branch requires and its " + "schema root does not, so there is nothing for an over-requirement to be " + "measured against and this test grades nothing — the shape has moved, not " + "the defect" + ) + assert any(name.endswith(".VerifyBrandClaimRequest") for name in with_shape), ( + "verify-brand-claim-request is the case this test exists for: its root requires " + "neither `claim` nor `claim_type` and all four of its branches require both. It " + "is the root whose relaxation the assertion below grades, and it is absent from " + f"the schema-side shape: {sorted(with_shape)}" + ) + assert any(name.endswith(".ListAccountChangesResponse") for name, _ in _POPULATION), ( + "list-account-changes-response has left the derived population, so this test no " + "longer looks at the root whose `status` is root-required ONLY through an allOf " + "$ref — the one shape that distinguishes a correct derivation from one reading " + f"`schema['required']` alone: {[name for name, _ in _POPULATION]}" + ) + + over = {name: _over_required_keys(root) for name, root in _POPULATION} + violations = {name: keys for name, keys in over.items() if keys} + assert not violations, ( + "generated split root(s) require keys their schema root does not, so the " + "branches' requiredness has been merged onto the root: " + + "; ".join(f"{name} requires {keys}" for name, keys in sorted(violations.items())) + + f" — the schema-side shape exists for {sorted(with_shape)}" + ) diff --git a/tests/conformance/test_request_branch_requiredness.py b/tests/conformance/test_request_branch_requiredness.py new file mode 100644 index 000000000..0734d338d --- /dev/null +++ b/tests/conformance/test_request_branch_requiredness.py @@ -0,0 +1,160 @@ +"""A request model may not require a field its schema root leaves to a branch. + +WHY THIS IS NOT COVERED BY ``test_schema_model_parity``. That module synthesizes +ONE document per tool, grows it to include the optional properties, and then +mutates LEAF VALUES at json pointers. A branch-selecting property is never +OMITTED, so the corpus cannot reach "drop ``creatives``, supply ``assignments``" +— and it does not. The parity suite passed 86/86 both with and against the defect +below; that is measured, not assumed, which is why this file exists rather than a +case being added there. + +THE DEFECT IT GRADES. ``creative/sync-creatives-request.json`` requires only +``[idempotency_key, account]`` at its root and then offers + + anyOf: [{required: [creatives]}, {required: [assignments]}, + {required: [assignment_operations]}] + +so a schema-valid request may carry ``assignments`` or ``assignment_operations`` +INSTEAD of ``creatives``. The public ``SyncCreativesRequest`` redeclared +``creatives`` as required, and the SDK therefore refused two of the three legal +request shapes. Measured against the pinned schema with ``jsonschema``, one +document per branch: + + assignments branch schema ACCEPTS model REFUSED + assignment_operations branch schema ACCEPTS model REFUSED + creatives branch schema ACCEPTS model accepted + +An ``anyOf`` branch's ``required`` is a condition on THAT branch, never on the +root. Narrowing a redeclared field's item type is right; dropping ``| None`` from +it is what made the other branches unreachable (salesagent-8qela.69). + +THE POPULATION IS DERIVED, and asserted non-empty before anything is compared, +because a derivation that matches nothing passes while grading nothing. + +SCOPED TO REQUESTS ON PURPOSE, AND DO NOT WIDEN IT TO ALL MODELS. The comparator +here — "the schema ROOT's ``required`` list" — assumes a public class binds that +root. Most do. Some bind a BRANCH deliberately, and for those the root is simply +the wrong thing to compare against. + +``canonical_creative.CreativeAsset`` is the worked example: it binds +``CreativeAssetV2CreativeCanonicalFormatKind``, the canonical-format branch, so +its required ``format_kind`` is that branch's contract and not an +over-requirement. The design is stated in the module docstring — the +wire-faithful models "are exposed from ``adcp.types.legacy``" while the canonical +models exclude legacy identity — and in nothing derivable, so a sweep reading +only schemas and ``model_fields`` cannot see it. Measured at both ends: +CreativeAsset ``format_kind`` is required at 65626be29 AND at head, while +CreativeManifest was optional upstream and required at head. Only the second was +a regression (salesagent-8qela.69); the first was a false positive from exactly +this comparator. + +An earlier version of this docstring said the widening was "blocked" and would +need CreativeAsset and Creative allowlisted. That was wrong, and wrong in the +expensive direction: it would have recorded a deliberate design as debt to be +paid off. They are not debt. The guard stays request-scoped because a request +model refusing a legal request is the severe case, and because widening it would +require teaching the comparator which classes bind a branch — which is a docstring +fact, not a derivable one. + +Before treating any hit from a root-comparison sweep as a defect, check what the +public class actually BINDS, on the resolved MRO rather than the declaration. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +import adcp.types as public_types +from adcp.validation.version import resolve_bundle_key + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_BUNDLE_KEY = resolve_bundle_key((_REPO_ROOT / "src" / "adcp" / "ADCP_VERSION").read_text().strip()) +_SCHEMA_DIR = _REPO_ROOT / "schemas" / "cache" / _BUNDLE_KEY + +#: Measured at the 3.2.1 pin: 8 request schemas select a branch purely by +#: ``required``, carrying 25 branches between them. A floor, not an equality — +#: the bundle grows between AdCP releases — and the population assertion this +#: test needs, since an empty population would compare an empty list and pass. +_MIN_BRANCH_SELECTING_SCHEMAS = 6 + + +def _branch_selecting_requests() -> list[tuple[Path, dict[str, Any], str, list[list[str]]]]: + """Every request schema whose root offers a choice stated purely as ``required``. + + "Purely" matters: a branch carrying ``properties`` or ``not`` alongside its + ``required`` is saying something about shape as well as presence, and reading + its ``required`` as optional-at-the-root would not follow. A branch whose only + keyword IS ``required`` says exactly "this property may be the one you send". + """ + found = [] + for path in sorted(_SCHEMA_DIR.rglob("*-request.json")): + relative = path.relative_to(_SCHEMA_DIR) + if relative.parts[0] in ("mcp", "bundled"): + continue + schema = json.loads(path.read_text()) + if not isinstance(schema, dict): + continue + for keyword in ("anyOf", "oneOf"): + pure = [ + branch + for branch in schema.get(keyword) or [] + if isinstance(branch, dict) and set(branch) == {"required"} + ] + if len(pure) >= 2: + found.append((relative, schema, keyword, [b["required"] for b in pure])) + return found + + +def _public_model(stem: str) -> Any: + """The public model for a schema stem, or ``None`` when the tool has no model.""" + return getattr( + public_types, "".join(w.capitalize() for w in stem.replace("-", " ").split()), None + ) + + +_POPULATION = _branch_selecting_requests() + + +def test_the_branch_selecting_population_is_not_empty() -> None: + """The derivation resolves, so the test below is comparing something.""" + assert len(_POPULATION) >= _MIN_BRANCH_SELECTING_SCHEMAS, ( + f"only {len(_POPULATION)} request schemas select a branch by `required`, expected at " + f"least {_MIN_BRANCH_SELECTING_SCHEMAS} — the derivation has stopped resolving and the " + "requiredness test below no longer grades anything" + ) + stems = {relative.stem for relative, *_ in _POPULATION} + assert "sync-creatives-request" in stems, ( + "sync-creatives-request is the known case this test was written for and it is " + f"absent from the derived population: {sorted(stems)}" + ) + + +@pytest.mark.parametrize( + ("relative", "schema", "keyword", "branches"), + _POPULATION, + ids=[str(relative) for relative, *_ in _POPULATION], +) +def test_no_request_model_requires_what_its_root_leaves_to_a_branch( + relative: Path, schema: dict[str, Any], keyword: str, branches: list[list[str]] +) -> None: + model = _public_model(relative.stem) + if model is None: + pytest.skip(f"{relative} has no public request model") + + root_required = set(schema.get("required") or []) + over_required = sorted( + name + for name, field in model.model_fields.items() + if field.is_required() and name not in root_required + ) + selected = sorted({name for branch in branches for name in branch}) + assert over_required == [], ( + f"{model.__name__} requires {over_required}, which {relative} does not require at its " + f"root (root requires {sorted(root_required)}). Its {keyword} offers {selected} as " + f"alternatives, so a document choosing another one is schema-valid and this model " + f"refuses it. A branch's `required` is a condition on that branch, not on the root." + ) diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 681079bb4..0cef2a39f 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -940,6 +940,7 @@ "CreateMediaBuyAuthentication": "media_buy.create_media_buy_request:Authentication", "CreateMediaBuyErrorResponse": "adcp.types.canonical_creative:CreateMediaBuyResponse2", "CreateMediaBuyRequest": "adcp.types.canonical_creative", + "CreateMediaBuyRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[adcp.types.canonical_creative:CreateMediaBuyRequestCommittedProposalExecution,adcp.types.canonical_creative:CreateMediaBuyRequestExplicitPackagesWithFixedAllocation,adcp.types.canonical_creative:CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation]]", "CreateMediaBuyResponse": "union[adcp.types.canonical_creative:CreateMediaBuyResponse1,adcp.types.canonical_creative:CreateMediaBuyResponse2,adcp.types.canonical_creative:CreateMediaBuyResponse3]", "CreateMediaBuyResponse1": "adcp.types.canonical_creative", "CreateMediaBuySubmittedResponse": "adcp.types.canonical_creative:CreateMediaBuyResponse3", @@ -956,6 +957,7 @@ "CreativeAssignment": "core.creative_assignment", "CreativeFilters": "adcp.types.canonical_creative", "CreativeManifest": "adcp.types.canonical_creative", + "CreativeManifestNamedFormatManifest": "core.creative_manifest", "CreativePolicy": "core.creative_policy", "CreativeRejectedDetails": "error_details.creative_rejected", "CreativeRepresentationUnresolvedDetails": "error_details.creative_representation_unresolved", @@ -1022,7 +1024,7 @@ "ForecastRangeUnit": "enums.forecast_range_unit", "ForecastableMetric": "enums.forecastable_metric", "Format": "adcp.types.canonical_creative", - "FormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[adcp.types.aliases:UnknownFormatAsset,instance:pydantic.types:Tag],union[core.format:Assets,instance:pydantic.types:Tag],union[core.format:Assets10,instance:pydantic.types:Tag],union[core.format:Assets11,instance:pydantic.types:Tag],union[core.format:Assets12,instance:pydantic.types:Tag],union[core.format:Assets13,instance:pydantic.types:Tag],union[core.format:Assets14,instance:pydantic.types:Tag],union[core.format:Assets15,instance:pydantic.types:Tag],union[core.format:Assets17,instance:pydantic.types:Tag],union[core.format:Assets18,instance:pydantic.types:Tag],union[core.format:Assets19,instance:pydantic.types:Tag],union[core.format:Assets20,instance:pydantic.types:Tag],union[core.format:Assets21,instance:pydantic.types:Tag],union[core.format:Assets22,instance:pydantic.types:Tag],union[core.format:Assets29,instance:pydantic.types:Tag],union[core.format:Assets9,instance:pydantic.types:Tag]]]", + "FormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets,instance:pydantic.types:Tag],union[core.format:Assets10,instance:pydantic.types:Tag],union[core.format:Assets11,instance:pydantic.types:Tag],union[core.format:Assets12,instance:pydantic.types:Tag],union[core.format:Assets13,instance:pydantic.types:Tag],union[core.format:Assets14,instance:pydantic.types:Tag],union[core.format:Assets15,instance:pydantic.types:Tag],union[core.format:Assets16,instance:pydantic.types:Tag],union[core.format:Assets17,instance:pydantic.types:Tag],union[core.format:Assets18,instance:pydantic.types:Tag],union[core.format:Assets19,instance:pydantic.types:Tag],union[core.format:Assets20,instance:pydantic.types:Tag],union[core.format:Assets21,instance:pydantic.types:Tag],union[core.format:Assets22,instance:pydantic.types:Tag],union[core.format:Assets23,instance:pydantic.types:Tag],union[core.format:Assets24,instance:pydantic.types:Tag],union[core.format:Assets25,instance:pydantic.types:Tag],union[core.format:Assets26,instance:pydantic.types:Tag],union[core.format:Assets27,instance:pydantic.types:Tag],union[core.format:Assets28,instance:pydantic.types:Tag],union[core.format:Assets29,instance:pydantic.types:Tag],union[core.format:Assets9,instance:pydantic.types:Tag],union[core.format:UnknownFormatAsset,instance:pydantic.types:Tag]]]", "FormatCard": "core.format", "FormatCardDetailed": "core.format", "FormatIdParameter": "enums.format_id_parameter", @@ -1111,7 +1113,7 @@ "GovernanceAgent": "account.sync_governance_request", "GovernanceAgentNotAcceptedDetails": "union[instance:pydantic.fields:FieldInfo,union[error_details.governance_agent_not_accepted:GovernanceAgentNotAcceptedDetails1,error_details.governance_agent_not_accepted:GovernanceAgentNotAcceptedDetails2]]", "GovernanceAuthentication": "account.sync_governance_request:Authentication", - "GroupFormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[adcp.types.aliases:UnknownGroupAsset,instance:pydantic.types:Tag],union[core.format:Assets31,instance:pydantic.types:Tag],union[core.format:Assets32,instance:pydantic.types:Tag],union[core.format:Assets33,instance:pydantic.types:Tag],union[core.format:Assets34,instance:pydantic.types:Tag],union[core.format:Assets35,instance:pydantic.types:Tag],union[core.format:Assets36,instance:pydantic.types:Tag],union[core.format:Assets37,instance:pydantic.types:Tag],union[core.format:Assets38,instance:pydantic.types:Tag],union[core.format:Assets40,instance:pydantic.types:Tag],union[core.format:Assets41,instance:pydantic.types:Tag],union[core.format:Assets42,instance:pydantic.types:Tag],union[core.format:Assets43,instance:pydantic.types:Tag]]]", + "GroupFormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets31,instance:pydantic.types:Tag],union[core.format:Assets32,instance:pydantic.types:Tag],union[core.format:Assets33,instance:pydantic.types:Tag],union[core.format:Assets34,instance:pydantic.types:Tag],union[core.format:Assets35,instance:pydantic.types:Tag],union[core.format:Assets36,instance:pydantic.types:Tag],union[core.format:Assets37,instance:pydantic.types:Tag],union[core.format:Assets38,instance:pydantic.types:Tag],union[core.format:Assets39,instance:pydantic.types:Tag],union[core.format:Assets40,instance:pydantic.types:Tag],union[core.format:Assets41,instance:pydantic.types:Tag],union[core.format:Assets42,instance:pydantic.types:Tag],union[core.format:Assets43,instance:pydantic.types:Tag],union[core.format:Assets44,instance:pydantic.types:Tag],union[core.format:Assets45,instance:pydantic.types:Tag],union[core.format:Assets46,instance:pydantic.types:Tag],union[core.format:Assets47,instance:pydantic.types:Tag],union[core.format:Assets48,instance:pydantic.types:Tag],union[core.format:Assets49,instance:pydantic.types:Tag],union[core.format:Assets50,instance:pydantic.types:Tag],union[core.format:Assets51,instance:pydantic.types:Tag],union[core.format:UnknownGroupAsset,instance:pydantic.types:Tag]]]", "Gtin": "core.catalog", "HtmlContent": "core.assets.html_asset:HtmlAsset", "HtmlFormatAsset": "core.format:Assets13", @@ -1192,6 +1194,7 @@ "LegacyUpdateMediaBuySuccessResponse": "media_buy.update_media_buy_response:UpdateMediaBuyResponse1", "ListAccountChangesRequest": "account.list_account_changes_request", "ListAccountChangesResponse": "account.list_account_changes_response", + "ListAccountChangesResponseBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[account.list_account_changes_response:ListAccountChangesResponseCompleted,account.list_account_changes_response:ListAccountChangesResponseFailed]]", "ListAccountsRequest": "account.list_accounts_request", "ListAccountsResponse": "account.list_accounts_response", "ListCollectionListsRequest": "collection.list_collection_lists_request", @@ -1441,6 +1444,7 @@ "Request": "creative.preview_creative_request", "RequestProposalsProductId": "media_buy.request_proposals_response:ProductId", "RequestProposalsRequest": "media_buy.request_proposals_request", + "RequestProposalsRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[media_buy.request_proposals_request:RequestProposalsRequestAccount,media_buy.request_proposals_request:RequestProposalsRequestBrand]]", "RequestProposalsResponse": "union[instance:pydantic.fields:FieldInfo,union[media_buy.request_proposals_response:RequestProposalsResponse1,media_buy.request_proposals_response:RequestProposalsResponse2,media_buy.request_proposals_response:RequestProposalsResponse3,media_buy.request_proposals_response:RequestProposalsResponse4]]", "RequestSigningRequiredForItem": "protocol.get_adcp_capabilities_response:RequiredForItem", "RequoteRequiredDetails": "error_details.requote_required", @@ -1569,8 +1573,8 @@ "Transform": "core.catalog_field_mapping", "TrustedMatch": "core.product", "Unit": "enums.dimension_unit:DimensionUnit", - "UnknownFormatAsset": "adcp.types.aliases", - "UnknownGroupAsset": "adcp.types.aliases", + "UnknownFormatAsset": "core.format", + "UnknownGroupAsset": "core.format", "UnsupportedRefinementDimensionDetails": "error_details.unsupported_refinement_dimension", "UpdateCollectionListRequest": "collection.update_collection_list_request", "UpdateCollectionListResponse": "collection.update_collection_list_response", @@ -1633,6 +1637,7 @@ "VenueBreakdownItem": "core.delivery_metrics", "VerifyBrandClaimPayload": "brand.verify_brand_claim_response", "VerifyBrandClaimRequest": "brand.verify_brand_claim_request", + "VerifyBrandClaimRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifyParentClaim,brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifyPropertyClaim,brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifySubsidiaryClaim,brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifyTrademarkClaim]]", "VerifyBrandClaimResponse": "union[brand.verify_brand_claim_response:VerifyBrandClaimErrorResponse,brand.verify_brand_claim_response:VerifyBrandClaimSuccessResponse]", "VerifyBrandClaimSignedResponse": "brand.verify_brand_claim_response", "VerifyBrandClaimSignedSuccessPayload": "brand.verify_brand_claim_response", @@ -1652,6 +1657,7 @@ "WcagLevel": "enums.wcag_level", "WebhookChallenge": "core.webhook_challenge", "WebhookChallengeResponse": "core.webhook_challenge_response", + "WebhookChallengeResponseBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.webhook_challenge_response:WebhookChallengeResponseChallenge,core.webhook_challenge_response:WebhookChallengeResponseToken]]", "WebhookContent": "core.assets.webhook_asset:WebhookAsset", "WebhookFormatAsset": "core.format:Assets20", "WebhookFormatGroupAsset": "core.format:Assets43", @@ -1994,6 +2000,9 @@ "AudienceActivationMethods4": "core.product_filters", "AudienceActivationMethods5": "core.product_filters", "AudienceCharacteristic": "core.audience_characteristic", + "AudienceCharacteristicBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.audience_characteristic:AudienceCharacteristicRange,core.audience_characteristic:AudienceCharacteristicValue]]", + "AudienceCharacteristicRange": "core.audience_characteristic", + "AudienceCharacteristicValue": "core.audience_characteristic", "AudienceConstraints": "governance.audience_constraints", "AudienceDistribution": "governance.check_governance_request", "AudienceDistribution1": "governance.check_governance_request", @@ -2346,6 +2355,10 @@ "CatalogSelection": "core.catalog_selection", "CatalogType": "enums.catalog_type", "Catchment": "core.catchment", + "CatchmentBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.catchment:CatchmentGeometry,core.catchment:CatchmentRadius,core.catchment:CatchmentTransportModeTravelTime]]", + "CatchmentGeometry": "core.catchment", + "CatchmentRadius": "core.catchment", + "CatchmentTransportModeTravelTime": "core.catchment", "Category": "core.event_surface", "ChangeFeed": "protocol.get_adcp_capabilities_response", "ChangeFeed1": "protocol.get_adcp_capabilities_response", @@ -2520,6 +2533,10 @@ "CreateContentStandardsResponse2": "content_standards.create_content_standards_response", "CreateMediaBuyInputRequired": "media_buy.create_media_buy_async_response_input_required", "CreateMediaBuyRequest": "media_buy.create_media_buy_request", + "CreateMediaBuyRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[media_buy.create_media_buy_request:CreateMediaBuyRequestCommittedProposalExecution,media_buy.create_media_buy_request:CreateMediaBuyRequestExplicitPackagesWithFixedAllocation,media_buy.create_media_buy_request:CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation]]", + "CreateMediaBuyRequestCommittedProposalExecution": "media_buy.create_media_buy_request", + "CreateMediaBuyRequestExplicitPackagesWithFixedAllocation": "media_buy.create_media_buy_request", + "CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation": "media_buy.create_media_buy_request", "CreateMediaBuyResponse": "union[media_buy.create_media_buy_response:CreateMediaBuyResponse1,media_buy.create_media_buy_response:CreateMediaBuyResponse2,media_buy.create_media_buy_response:CreateMediaBuyResponse3]", "CreateMediaBuyResponse1": "media_buy.create_media_buy_response", "CreateMediaBuyResponse2": "media_buy.create_media_buy_response", @@ -2543,6 +2560,9 @@ "CreativeApprovalResponse4": "brand.creative_approval_response", "CreativeApprovalStatus": "enums.creative_approval_status", "CreativeAsset": "core.creative_asset", + "CreativeAssetBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.creative_asset:CreativeAssetV1CreativeNamedFormatReference,core.creative_asset:CreativeAssetV2CreativeCanonicalFormatKind]]", + "CreativeAssetV1CreativeNamedFormatReference": "core.creative_asset", + "CreativeAssetV2CreativeCanonicalFormatKind": "core.creative_asset", "CreativeAssets": "core.creative_assets", "CreativeAssets1": "core.creative_assets", "CreativeAssignment": "core.creative_assignment", @@ -2563,6 +2583,9 @@ "CreativeLocalization": "core.creative_localization", "CreativeLocalizationReadback": "core.creative_localization_readback", "CreativeManifest": "core.creative_manifest", + "CreativeManifestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.creative_manifest:CreativeManifestCanonicalFormatManifest,core.creative_manifest:CreativeManifestNamedFormatManifest]]", + "CreativeManifestCanonicalFormatManifest": "core.creative_manifest", + "CreativeManifestNamedFormatManifest": "core.creative_manifest", "CreativeMotionLevel": "enums.motion_level", "CreativeOperationFormatDeclaration": "core.creative_operation_format_declaration", "CreativePolicy": "core.creative_policy", @@ -2838,6 +2861,7 @@ "ForecastVendorMetricValue": "core.forecast_vendor_metric_value", "ForecastableMetric": "enums.forecastable_metric", "Format": "core.format", + "FormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets,instance:pydantic.types:Tag],union[core.format:Assets10,instance:pydantic.types:Tag],union[core.format:Assets11,instance:pydantic.types:Tag],union[core.format:Assets12,instance:pydantic.types:Tag],union[core.format:Assets13,instance:pydantic.types:Tag],union[core.format:Assets14,instance:pydantic.types:Tag],union[core.format:Assets15,instance:pydantic.types:Tag],union[core.format:Assets16,instance:pydantic.types:Tag],union[core.format:Assets17,instance:pydantic.types:Tag],union[core.format:Assets18,instance:pydantic.types:Tag],union[core.format:Assets19,instance:pydantic.types:Tag],union[core.format:Assets20,instance:pydantic.types:Tag],union[core.format:Assets21,instance:pydantic.types:Tag],union[core.format:Assets22,instance:pydantic.types:Tag],union[core.format:Assets23,instance:pydantic.types:Tag],union[core.format:Assets24,instance:pydantic.types:Tag],union[core.format:Assets25,instance:pydantic.types:Tag],union[core.format:Assets26,instance:pydantic.types:Tag],union[core.format:Assets27,instance:pydantic.types:Tag],union[core.format:Assets28,instance:pydantic.types:Tag],union[core.format:Assets29,instance:pydantic.types:Tag],union[core.format:Assets9,instance:pydantic.types:Tag],union[core.format:UnknownFormatAsset,instance:pydantic.types:Tag]]]", "FormatCard": "core.format", "FormatCardDetailed": "core.format", "FormatId": "media_buy.acceptance_policy_requirement", @@ -2855,7 +2879,13 @@ "FrequencyCapControlMode": "enums.frequency_cap_control_mode", "FrequencyCapDurationUnit": "core.frequency_cap_duration_unit", "FrequencyCapImpressionConstraints": "core.frequency_cap_impression_constraints", + "FrequencyCapImpressionConstraintsAllowedValues": "core.frequency_cap_impression_constraints", + "FrequencyCapImpressionConstraintsBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.frequency_cap_impression_constraints:FrequencyCapImpressionConstraintsAllowedValues,core.frequency_cap_impression_constraints:FrequencyCapImpressionConstraintsMaximumMinimum]]", + "FrequencyCapImpressionConstraintsMaximumMinimum": "core.frequency_cap_impression_constraints", "FrequencyCapIntervalConstraints": "core.frequency_cap_interval_constraints", + "FrequencyCapIntervalConstraintsAllowedIntervals": "core.frequency_cap_interval_constraints", + "FrequencyCapIntervalConstraintsBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.frequency_cap_interval_constraints:FrequencyCapIntervalConstraintsAllowedIntervals,core.frequency_cap_interval_constraints:FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval]]", + "FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval": "core.frequency_cap_interval_constraints", "FrequencyCapMutableField": "enums.frequency_cap_mutable_field", "FrequencyCapRequirements": "core.frequency_cap_requirements", "FrequencyCapScope": "enums.frequency_cap_scope", @@ -2924,6 +2954,9 @@ "GetCreativeFeaturesSubmitted": "creative.get_creative_features_async_response_submitted", "GetCreativeFeaturesSuccess": "creative.get_creative_features_terminal_success", "GetGeographicPlaceResolutionRequest": "core.get_geo_place_resolution_request", + "GetGeographicPlaceResolutionRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.get_geo_place_resolution_request:GetGeographicPlaceResolutionRequestQ,core.get_geo_place_resolution_request:GetGeographicPlaceResolutionRequestValue]]", + "GetGeographicPlaceResolutionRequestQ": "core.get_geo_place_resolution_request", + "GetGeographicPlaceResolutionRequestValue": "core.get_geo_place_resolution_request", "GetGeographicPlaceResolutionResponse": "core.get_geo_place_resolution_response", "GetMediaBuyArtifactsRequest": "content_standards.get_media_buy_artifacts_request", "GetMediaBuyArtifactsResponse": "union[content_standards.get_media_buy_artifacts_response:GetMediaBuyArtifactsResponse1,content_standards.get_media_buy_artifacts_response:GetMediaBuyArtifactsResponse2]", @@ -2974,6 +3007,7 @@ "GovernedAction": "governance.get_plan_audit_logs_response", "GradingProfile": "core.registry_event", "GrantStatus": "core.rights_constraint", + "GroupFormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets31,instance:pydantic.types:Tag],union[core.format:Assets32,instance:pydantic.types:Tag],union[core.format:Assets33,instance:pydantic.types:Tag],union[core.format:Assets34,instance:pydantic.types:Tag],union[core.format:Assets35,instance:pydantic.types:Tag],union[core.format:Assets36,instance:pydantic.types:Tag],union[core.format:Assets37,instance:pydantic.types:Tag],union[core.format:Assets38,instance:pydantic.types:Tag],union[core.format:Assets39,instance:pydantic.types:Tag],union[core.format:Assets40,instance:pydantic.types:Tag],union[core.format:Assets41,instance:pydantic.types:Tag],union[core.format:Assets42,instance:pydantic.types:Tag],union[core.format:Assets43,instance:pydantic.types:Tag],union[core.format:Assets44,instance:pydantic.types:Tag],union[core.format:Assets45,instance:pydantic.types:Tag],union[core.format:Assets46,instance:pydantic.types:Tag],union[core.format:Assets47,instance:pydantic.types:Tag],union[core.format:Assets48,instance:pydantic.types:Tag],union[core.format:Assets49,instance:pydantic.types:Tag],union[core.format:Assets50,instance:pydantic.types:Tag],union[core.format:Assets51,instance:pydantic.types:Tag],union[core.format:UnknownGroupAsset,instance:pydantic.types:Tag]]]", "Gtin": "core.catalog", "Handoff": "sponsored_intelligence.si_send_message_response", "HdrFormat": "core.assets.video_asset", @@ -3139,6 +3173,9 @@ "List": "a2ui.si_catalog", "ListAccountChangesRequest": "account.list_account_changes_request", "ListAccountChangesResponse": "account.list_account_changes_response", + "ListAccountChangesResponseBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[account.list_account_changes_response:ListAccountChangesResponseCompleted,account.list_account_changes_response:ListAccountChangesResponseFailed]]", + "ListAccountChangesResponseCompleted": "account.list_account_changes_response", + "ListAccountChangesResponseFailed": "account.list_account_changes_response", "ListAccountsRequest": "account.list_accounts_request", "ListAccountsResponse": "account.list_accounts_response", "ListCollectionListsRequest": "collection.list_collection_lists_request", @@ -3942,6 +3979,9 @@ "Request": "creative.preview_creative_request", "RequestProposalsInputRequired": "media_buy.request_proposals_async_response_input_required", "RequestProposalsRequest": "media_buy.request_proposals_request", + "RequestProposalsRequestAccount": "media_buy.request_proposals_request", + "RequestProposalsRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[media_buy.request_proposals_request:RequestProposalsRequestAccount,media_buy.request_proposals_request:RequestProposalsRequestBrand]]", + "RequestProposalsRequestBrand": "media_buy.request_proposals_request", "RequestProposalsResponse": "union[instance:pydantic.fields:FieldInfo,union[media_buy.request_proposals_response:RequestProposalsResponse1,media_buy.request_proposals_response:RequestProposalsResponse2,media_buy.request_proposals_response:RequestProposalsResponse3,media_buy.request_proposals_response:RequestProposalsResponse4]]", "RequestProposalsResponse1": "media_buy.request_proposals_response", "RequestProposalsResponse2": "media_buy.request_proposals_response", @@ -4054,8 +4094,11 @@ "ScrollReference": "formats.canonical.seller_rendered_stateful_display", "SealedCredential": "trusted_match.identity_match_request", "SearchBrandResult": "brand.search_brands_response", + "SearchBrandsError": "brand.search_brands_response", "SearchBrandsRequest": "brand.search_brands_request", "SearchBrandsResponse": "brand.search_brands_response", + "SearchBrandsResponseBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[brand.search_brands_response:SearchBrandsError,brand.search_brands_response:SearchBrandsSuccess]]", + "SearchBrandsSuccess": "brand.search_brands_response", "Security": "core.assets.webhook_asset", "SeedSource": "core.signal_definition", "Segment": "creative.video_brief", @@ -4428,6 +4471,8 @@ "UnavailableReason": "core.account_change", "Unit": "core.audience_evidence", "UniversalMacro": "enums.universal_macro", + "UnknownFormatAsset": "core.format", + "UnknownGroupAsset": "core.format", "UnknownHandling": "core.demographic_targeting_capability", "UnmatchedLocaleAction": "core.creative_localization", "UnmetRequirement": "error_details.execution_requirement_unmet", @@ -4551,6 +4596,11 @@ "VerifyBrandClaimErrorResponse": "brand.verify_brand_claim_response", "VerifyBrandClaimPayload": "brand.verify_brand_claim_response", "VerifyBrandClaimRequest": "brand.verify_brand_claim_request", + "VerifyBrandClaimRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifyParentClaim,brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifyPropertyClaim,brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifySubsidiaryClaim,brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifyTrademarkClaim]]", + "VerifyBrandClaimRequestVerifyParentClaim": "brand.verify_brand_claim_request", + "VerifyBrandClaimRequestVerifyPropertyClaim": "brand.verify_brand_claim_request", + "VerifyBrandClaimRequestVerifySubsidiaryClaim": "brand.verify_brand_claim_request", + "VerifyBrandClaimRequestVerifyTrademarkClaim": "brand.verify_brand_claim_request", "VerifyBrandClaimResponse": "union[brand.verify_brand_claim_response:VerifyBrandClaimErrorResponse,brand.verify_brand_claim_response:VerifyBrandClaimSuccessResponse]", "VerifyBrandClaimSignedResponse": "brand.verify_brand_claim_response", "VerifyBrandClaimSignedSuccessPayload": "brand.verify_brand_claim_response", @@ -4599,6 +4649,9 @@ "WebhookAssetRequirements": "core.requirements.webhook_asset_requirements", "WebhookChallenge": "core.webhook_challenge", "WebhookChallengeResponse": "core.webhook_challenge_response", + "WebhookChallengeResponseBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.webhook_challenge_response:WebhookChallengeResponseChallenge,core.webhook_challenge_response:WebhookChallengeResponseToken]]", + "WebhookChallengeResponseChallenge": "core.webhook_challenge_response", + "WebhookChallengeResponseToken": "core.webhook_challenge_response", "WebhookResponseType": "enums.webhook_response_type", "WebhookSecurityMethod": "enums.webhook_security_method", "WebhookSigning": "protocol.get_adcp_capabilities_response", @@ -4706,6 +4759,9 @@ "LastTopUp": "account.get_account_financials_response", "ListAccountChangesRequest": "account.list_account_changes_request", "ListAccountChangesResponse": "account.list_account_changes_response", + "ListAccountChangesResponseBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[account.list_account_changes_response:ListAccountChangesResponseCompleted,account.list_account_changes_response:ListAccountChangesResponseFailed]]", + "ListAccountChangesResponseCompleted": "account.list_account_changes_response", + "ListAccountChangesResponseFailed": "account.list_account_changes_response", "ListAccountsRequest": "account.list_accounts_request", "ListAccountsResponse": "account.list_accounts_response", "ReportUsageRequest": "account.report_usage_request", @@ -4869,8 +4925,11 @@ "RightsPricingOption": "brand.rights_pricing_option", "RightsTerms": "brand.rights_terms", "SearchBrandResult": "brand.search_brands_response", + "SearchBrandsError": "brand.search_brands_response", "SearchBrandsRequest": "brand.search_brands_request", "SearchBrandsResponse": "brand.search_brands_response", + "SearchBrandsResponseBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[brand.search_brands_response:SearchBrandsError,brand.search_brands_response:SearchBrandsSuccess]]", + "SearchBrandsSuccess": "brand.search_brands_response", "Store": "brand.verify_brand_claims_request", "Tone": "brand.get_brand_identity_response", "UpdateRightsRequest": "brand.update_rights_request", @@ -4882,6 +4941,11 @@ "VerifyBrandClaimErrorResponse": "brand.verify_brand_claim_response", "VerifyBrandClaimPayload": "brand.verify_brand_claim_response", "VerifyBrandClaimRequest": "brand.verify_brand_claim_request", + "VerifyBrandClaimRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifyParentClaim,brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifyPropertyClaim,brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifySubsidiaryClaim,brand.verify_brand_claim_request:VerifyBrandClaimRequestVerifyTrademarkClaim]]", + "VerifyBrandClaimRequestVerifyParentClaim": "brand.verify_brand_claim_request", + "VerifyBrandClaimRequestVerifyPropertyClaim": "brand.verify_brand_claim_request", + "VerifyBrandClaimRequestVerifySubsidiaryClaim": "brand.verify_brand_claim_request", + "VerifyBrandClaimRequestVerifyTrademarkClaim": "brand.verify_brand_claim_request", "VerifyBrandClaimResponse": "union[brand.verify_brand_claim_response:VerifyBrandClaimErrorResponse,brand.verify_brand_claim_response:VerifyBrandClaimSuccessResponse]", "VerifyBrandClaimSignedResponse": "brand.verify_brand_claim_response", "VerifyBrandClaimSignedSuccessPayload": "brand.verify_brand_claim_response", @@ -5349,6 +5413,9 @@ "AudienceActivationMethods4": "core.product_filters", "AudienceActivationMethods5": "core.product_filters", "AudienceCharacteristic": "core.audience_characteristic", + "AudienceCharacteristicBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.audience_characteristic:AudienceCharacteristicRange,core.audience_characteristic:AudienceCharacteristicValue]]", + "AudienceCharacteristicRange": "core.audience_characteristic", + "AudienceCharacteristicValue": "core.audience_characteristic", "AudienceEvidence": "core.audience_evidence", "AudienceEvidencePin": "core.audience_evidence_pin", "AudienceEvidenceRequirements": "core.audience_evidence_requirements", @@ -5515,6 +5582,10 @@ "CatalogSelection": "core.catalog_selection", "CatalogType": "core.assets.asset_union", "Catchment": "core.catchment", + "CatchmentBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.catchment:CatchmentGeometry,core.catchment:CatchmentRadius,core.catchment:CatchmentTransportModeTravelTime]]", + "CatchmentGeometry": "core.catchment", + "CatchmentRadius": "core.catchment", + "CatchmentTransportModeTravelTime": "core.catchment", "Category": "core.event_surface", "ChangedFields": "core.registry_event", "Classification": "core.registry_event", @@ -5571,6 +5642,9 @@ "CreateMediaBuySubmitted": "core.async_response_refs.media_buy.create_media_buy_async_response_submitted", "CreateMediaBuyWorking": "core.async_response_refs.media_buy.create_media_buy_async_response_working", "CreativeAsset": "core.creative_asset", + "CreativeAssetBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.creative_asset:CreativeAssetV1CreativeNamedFormatReference,core.creative_asset:CreativeAssetV2CreativeCanonicalFormatKind]]", + "CreativeAssetV1CreativeNamedFormatReference": "core.creative_asset", + "CreativeAssetV2CreativeCanonicalFormatKind": "core.creative_asset", "CreativeAssets": "core.creative_assets", "CreativeAssets1": "core.creative_assets", "CreativeAssignment": "core.creative_assignment", @@ -5584,6 +5658,9 @@ "CreativeLocalization": "core.creative_localization", "CreativeLocalizationReadback": "core.creative_localization_readback", "CreativeManifest": "core.creative_manifest", + "CreativeManifestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.creative_manifest:CreativeManifestCanonicalFormatManifest,core.creative_manifest:CreativeManifestNamedFormatManifest]]", + "CreativeManifestCanonicalFormatManifest": "core.creative_manifest", + "CreativeManifestNamedFormatManifest": "core.creative_manifest", "CreativeOperationFormatDeclaration": "core.creative_operation_format_declaration", "CreativePolicy": "core.creative_policy", "CreativeRepresentation": "core.creative_representation", @@ -5720,6 +5797,7 @@ "ForecastRange": "core.forecast_range", "ForecastRateRange": "core.forecast_rate_range", "ForecastVendorMetricValue": "core.forecast_vendor_metric_value", + "FormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets,instance:pydantic.types:Tag],union[core.format:Assets10,instance:pydantic.types:Tag],union[core.format:Assets11,instance:pydantic.types:Tag],union[core.format:Assets12,instance:pydantic.types:Tag],union[core.format:Assets13,instance:pydantic.types:Tag],union[core.format:Assets14,instance:pydantic.types:Tag],union[core.format:Assets15,instance:pydantic.types:Tag],union[core.format:Assets16,instance:pydantic.types:Tag],union[core.format:Assets17,instance:pydantic.types:Tag],union[core.format:Assets18,instance:pydantic.types:Tag],union[core.format:Assets19,instance:pydantic.types:Tag],union[core.format:Assets20,instance:pydantic.types:Tag],union[core.format:Assets21,instance:pydantic.types:Tag],union[core.format:Assets22,instance:pydantic.types:Tag],union[core.format:Assets23,instance:pydantic.types:Tag],union[core.format:Assets24,instance:pydantic.types:Tag],union[core.format:Assets25,instance:pydantic.types:Tag],union[core.format:Assets26,instance:pydantic.types:Tag],union[core.format:Assets27,instance:pydantic.types:Tag],union[core.format:Assets28,instance:pydantic.types:Tag],union[core.format:Assets29,instance:pydantic.types:Tag],union[core.format:Assets9,instance:pydantic.types:Tag],union[core.format:UnknownFormatAsset,instance:pydantic.types:Tag]]]", "FormatCard": "core.format", "FormatCardDetailed": "core.format", "FormatKind": "core.canonical_format_option", @@ -5734,7 +5812,13 @@ "FrequencyCapConstraints": "core.frequency_cap_constraints", "FrequencyCapDurationUnit": "core.frequency_cap_duration_unit", "FrequencyCapImpressionConstraints": "core.frequency_cap_impression_constraints", + "FrequencyCapImpressionConstraintsAllowedValues": "core.frequency_cap_impression_constraints", + "FrequencyCapImpressionConstraintsBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.frequency_cap_impression_constraints:FrequencyCapImpressionConstraintsAllowedValues,core.frequency_cap_impression_constraints:FrequencyCapImpressionConstraintsMaximumMinimum]]", + "FrequencyCapImpressionConstraintsMaximumMinimum": "core.frequency_cap_impression_constraints", "FrequencyCapIntervalConstraints": "core.frequency_cap_interval_constraints", + "FrequencyCapIntervalConstraintsAllowedIntervals": "core.frequency_cap_interval_constraints", + "FrequencyCapIntervalConstraintsBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.frequency_cap_interval_constraints:FrequencyCapIntervalConstraintsAllowedIntervals,core.frequency_cap_interval_constraints:FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval]]", + "FrequencyCapIntervalConstraintsMaximumIntervalMinimumInterval": "core.frequency_cap_interval_constraints", "FrequencyCapRequirements": "core.frequency_cap_requirements", "Freshness": "core.registry_feed_response", "FuelType": "core.vehicle_item", @@ -5763,6 +5847,9 @@ "GeographicRegionSupport": "core.geo_region_support", "GetCreativeFeaturesSubmitted": "core.async_response_refs.creative.get_creative_features_async_response_submitted", "GetGeographicPlaceResolutionRequest": "core.get_geo_place_resolution_request", + "GetGeographicPlaceResolutionRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.get_geo_place_resolution_request:GetGeographicPlaceResolutionRequestQ,core.get_geo_place_resolution_request:GetGeographicPlaceResolutionRequestValue]]", + "GetGeographicPlaceResolutionRequestQ": "core.get_geo_place_resolution_request", + "GetGeographicPlaceResolutionRequestValue": "core.get_geo_place_resolution_request", "GetGeographicPlaceResolutionResponse": "core.get_geo_place_resolution_response", "GetProductsInputRequired": "core.async_response_refs.media_buy.get_products_async_response_input_required", "GetProductsSubmitted": "core.async_response_refs.media_buy.get_products_async_response_submitted", @@ -5774,6 +5861,7 @@ "GovernanceAgent": "core.account", "GradingProfile": "core.registry_event", "GrantStatus": "core.rights_constraint", + "GroupFormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets31,instance:pydantic.types:Tag],union[core.format:Assets32,instance:pydantic.types:Tag],union[core.format:Assets33,instance:pydantic.types:Tag],union[core.format:Assets34,instance:pydantic.types:Tag],union[core.format:Assets35,instance:pydantic.types:Tag],union[core.format:Assets36,instance:pydantic.types:Tag],union[core.format:Assets37,instance:pydantic.types:Tag],union[core.format:Assets38,instance:pydantic.types:Tag],union[core.format:Assets39,instance:pydantic.types:Tag],union[core.format:Assets40,instance:pydantic.types:Tag],union[core.format:Assets41,instance:pydantic.types:Tag],union[core.format:Assets42,instance:pydantic.types:Tag],union[core.format:Assets43,instance:pydantic.types:Tag],union[core.format:Assets44,instance:pydantic.types:Tag],union[core.format:Assets45,instance:pydantic.types:Tag],union[core.format:Assets46,instance:pydantic.types:Tag],union[core.format:Assets47,instance:pydantic.types:Tag],union[core.format:Assets48,instance:pydantic.types:Tag],union[core.format:Assets49,instance:pydantic.types:Tag],union[core.format:Assets50,instance:pydantic.types:Tag],union[core.format:Assets51,instance:pydantic.types:Tag],union[core.format:UnknownGroupAsset,instance:pydantic.types:Tag]]]", "HistoryItem": "core.tasks_get_response", "HotelItem": "core.hotel_item", "HtmlAssetRequirements": "core.requirements.html_asset_requirements", @@ -6549,6 +6637,8 @@ "Trust": "core.brand_response_authorization_result", "UnavailableReason": "core.account_change", "UniversalMacro": "core.assets.asset_union", + "UnknownFormatAsset": "core.format", + "UnknownGroupAsset": "core.format", "UnknownHandling": "core.demographic_targeting_capability", "UpdateFrequency": "core.assets.asset_union", "UpdateMediaBuyInputRequired": "core.async_response_refs.media_buy.update_media_buy_async_response_input_required", @@ -6621,6 +6711,9 @@ "WebhookAssetRequirements": "core.requirements.webhook_asset_requirements", "WebhookChallenge": "core.webhook_challenge", "WebhookChallengeResponse": "core.webhook_challenge_response", + "WebhookChallengeResponseBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[core.webhook_challenge_response:WebhookChallengeResponseChallenge,core.webhook_challenge_response:WebhookChallengeResponseToken]]", + "WebhookChallengeResponseChallenge": "core.webhook_challenge_response", + "WebhookChallengeResponseToken": "core.webhook_challenge_response", "WebhookResponseType": "core.assets.asset_union", "WebhookSecurityMethod": "core.assets.asset_union", "WebhookSigningAlgorithm": "core.principal_declarations", @@ -7306,6 +7399,10 @@ "Cpm": "media_buy.proposal_refinement", "CreateMediaBuyInputRequired": "media_buy.create_media_buy_async_response_input_required", "CreateMediaBuyRequest": "media_buy.create_media_buy_request", + "CreateMediaBuyRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[media_buy.create_media_buy_request:CreateMediaBuyRequestCommittedProposalExecution,media_buy.create_media_buy_request:CreateMediaBuyRequestExplicitPackagesWithFixedAllocation,media_buy.create_media_buy_request:CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation]]", + "CreateMediaBuyRequestCommittedProposalExecution": "media_buy.create_media_buy_request", + "CreateMediaBuyRequestExplicitPackagesWithFixedAllocation": "media_buy.create_media_buy_request", + "CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation": "media_buy.create_media_buy_request", "CreateMediaBuyResponse": "union[media_buy.create_media_buy_response:CreateMediaBuyResponse1,media_buy.create_media_buy_response:CreateMediaBuyResponse2,media_buy.create_media_buy_response:CreateMediaBuyResponse3]", "CreateMediaBuyResponse1": "media_buy.create_media_buy_response", "CreateMediaBuyResponse2": "media_buy.create_media_buy_response", @@ -7489,6 +7586,9 @@ "ReportingStatusView": "media_buy.get_reporting_status_request", "RequestProposalsInputRequired": "media_buy.request_proposals_async_response_input_required", "RequestProposalsRequest": "media_buy.request_proposals_request", + "RequestProposalsRequestAccount": "media_buy.request_proposals_request", + "RequestProposalsRequestBranches": "union[instance:pydantic.functional_validators:WrapValidator,union[media_buy.request_proposals_request:RequestProposalsRequestAccount,media_buy.request_proposals_request:RequestProposalsRequestBrand]]", + "RequestProposalsRequestBrand": "media_buy.request_proposals_request", "RequestProposalsResponse": "union[instance:pydantic.fields:FieldInfo,union[media_buy.request_proposals_response:RequestProposalsResponse1,media_buy.request_proposals_response:RequestProposalsResponse2,media_buy.request_proposals_response:RequestProposalsResponse3,media_buy.request_proposals_response:RequestProposalsResponse4]]", "RequestProposalsResponse1": "media_buy.request_proposals_response", "RequestProposalsResponse2": "media_buy.request_proposals_response", diff --git a/tests/fixtures/validation-model-divergences.json b/tests/fixtures/validation-model-divergences.json index a93c52f58..0967ef424 100644 --- a/tests/fixtures/validation-model-divergences.json +++ b/tests/fixtures/validation-model-divergences.json @@ -1,14 +1 @@ -{ - "get_signals.brief_requires_selector": { - "keyword": "else/anyOf/required", - "reason": "datamodel-code-generator does not emit the top-level brief-mode conditional" - }, - "get_signals.wholesale_forbids_selector": { - "keyword": "if/then/not", - "reason": "datamodel-code-generator does not emit the top-level wholesale-mode conditional" - }, - "get_signals.pricing_requires_feed_version": { - "keyword": "dependencies", - "reason": "datamodel-code-generator does not emit JSON Schema property dependencies" - } -} +{} diff --git a/tests/fixtures/validation-parity-cases.json b/tests/fixtures/validation-parity-cases.json index 86607b39d..c19a5d1e9 100644 --- a/tests/fixtures/validation-parity-cases.json +++ b/tests/fixtures/validation-parity-cases.json @@ -4,7 +4,9 @@ "tool": "get_signals", "direction": "request", "model": "GetSignalsRequest", - "payload": {"signal_spec": "sports fans"}, + "payload": { + "signal_spec": "sports fans" + }, "canonical_valid": true, "pydantic_valid": true }, @@ -13,7 +15,9 @@ "tool": "get_signals", "direction": "request", "model": "GetSignalsRequest", - "payload": {"discovery_mode": "wholesale"}, + "payload": { + "discovery_mode": "wholesale" + }, "canonical_valid": true, "pydantic_valid": true }, @@ -24,16 +28,19 @@ "model": "GetSignalsRequest", "payload": {}, "canonical_valid": false, - "pydantic_valid": true + "pydantic_valid": false }, { "id": "get_signals.wholesale_forbids_selector", "tool": "get_signals", "direction": "request", "model": "GetSignalsRequest", - "payload": {"discovery_mode": "wholesale", "signal_spec": "sports fans"}, + "payload": { + "discovery_mode": "wholesale", + "signal_spec": "sports fans" + }, "canonical_valid": false, - "pydantic_valid": true + "pydantic_valid": false }, { "id": "get_signals.pricing_requires_feed_version", @@ -45,14 +52,16 @@ "if_pricing_version": "pricing-v1" }, "canonical_valid": false, - "pydantic_valid": true + "pydantic_valid": false }, { "id": "get_signals.signal_refs_min_items", "tool": "get_signals", "direction": "request", "model": "GetSignalsRequest", - "payload": {"signal_refs": []}, + "payload": { + "signal_refs": [] + }, "canonical_valid": false, "pydantic_valid": false }, @@ -61,7 +70,12 @@ "tool": "get_signals", "direction": "request", "model": "GetSignalsRequest", - "payload": {"signal_spec": "sports fans", "countries": ["usa"]}, + "payload": { + "signal_spec": "sports fans", + "countries": [ + "usa" + ] + }, "canonical_valid": false, "pydantic_valid": false }, @@ -70,7 +84,274 @@ "tool": "get_signals", "direction": "request", "model": "GetSignalsRequest", - "payload": {"signal_spec": "sports fans", "max_results": 0}, + "payload": { + "signal_spec": "sports fans", + "max_results": 0 + }, + "canonical_valid": false, + "pydantic_valid": false + }, + { + "id": "create_media_buy.packages_without_package_budget", + "tool": "create_media_buy", + "direction": "request", + "model": "CreateMediaBuyRequest", + "payload": { + "account": { + "account_id": "acct_test" + }, + "brand": { + "domain": "example.com" + }, + "idempotency_key": "0123456789abcdef0123", + "start_time": "2026-10-07T00:00:00+00:00", + "end_time": "2026-11-05T00:00:00+00:00", + "packages": [ + { + "product_id": "prod_1", + "pricing_option_id": "cpm_usd_fixed" + } + ], + "budget_allocation": { + "mode": "fixed" + } + }, + "canonical_valid": false, + "pydantic_valid": false + }, + { + "id": "create_media_buy.packages_with_min_spend_target", + "tool": "create_media_buy", + "direction": "request", + "model": "CreateMediaBuyRequest", + "payload": { + "account": { + "account_id": "acct_test" + }, + "brand": { + "domain": "example.com" + }, + "idempotency_key": "0123456789abcdef0123", + "start_time": "2026-10-07T00:00:00+00:00", + "end_time": "2026-11-05T00:00:00+00:00", + "packages": [ + { + "product_id": "prod_1", + "pricing_option_id": "cpm_usd_fixed", + "budget": 1000, + "min_spend_target": 500 + } + ], + "budget_allocation": { + "mode": "fixed" + } + }, + "canonical_valid": false, + "pydantic_valid": false + }, + { + "id": "create_media_buy.packages_with_proposal_id", + "tool": "create_media_buy", + "direction": "request", + "model": "CreateMediaBuyRequest", + "payload": { + "account": { + "account_id": "acct_test" + }, + "brand": { + "domain": "example.com" + }, + "idempotency_key": "0123456789abcdef0123", + "start_time": "2026-10-07T00:00:00+00:00", + "end_time": "2026-11-05T00:00:00+00:00", + "packages": [ + { + "product_id": "prod_1", + "pricing_option_id": "cpm_usd_fixed", + "budget": 1000 + } + ], + "budget_allocation": { + "mode": "fixed" + }, + "proposal_id": "prop_1", + "total_budget": { + "amount": 1000, + "currency": "USD" + } + }, + "canonical_valid": false, + "pydantic_valid": false + }, + { + "id": "create_media_buy.proposal_with_budget_allocation", + "tool": "create_media_buy", + "direction": "request", + "model": "CreateMediaBuyRequest", + "payload": { + "account": { + "account_id": "acct_test" + }, + "brand": { + "domain": "example.com" + }, + "idempotency_key": "0123456789abcdef0123", + "start_time": "2026-10-07T00:00:00+00:00", + "end_time": "2026-11-05T00:00:00+00:00", + "proposal_id": "prop_1", + "total_budget": { + "amount": 1000, + "currency": "USD" + }, + "budget_allocation": { + "mode": "fixed" + } + }, + "canonical_valid": false, + "pydantic_valid": false + }, + { + "id": "create_media_buy.seller_optimized_without_total_budget", + "tool": "create_media_buy", + "direction": "request", + "model": "CreateMediaBuyRequest", + "payload": { + "account": { + "account_id": "acct_test" + }, + "brand": { + "domain": "example.com" + }, + "idempotency_key": "0123456789abcdef0123", + "start_time": "2026-10-07T00:00:00+00:00", + "end_time": "2026-11-05T00:00:00+00:00", + "packages": [ + { + "product_id": "prod_1", + "pricing_option_id": "cpm_usd_fixed", + "budget": 1000 + } + ], + "budget_allocation": { + "mode": "seller_optimized", + "optimization_goals": [ + { + "kind": "metric", + "metric": "clicks" + } + ] + } + }, + "canonical_valid": false, + "pydantic_valid": false + }, + { + "id": "create_media_buy.valid_fixed_allocation", + "tool": "create_media_buy", + "direction": "request", + "model": "CreateMediaBuyRequest", + "payload": { + "account": { + "account_id": "acct_test" + }, + "brand": { + "domain": "example.com" + }, + "idempotency_key": "0123456789abcdef0123", + "start_time": "2026-10-07T00:00:00+00:00", + "end_time": "2026-11-05T00:00:00+00:00", + "packages": [ + { + "product_id": "prod_1", + "pricing_option_id": "cpm_usd_fixed", + "budget": 1000 + } + ], + "budget_allocation": { + "mode": "fixed" + } + }, + "canonical_valid": true, + "pydantic_valid": true + }, + { + "id": "create_media_buy.valid_seller_optimized", + "tool": "create_media_buy", + "direction": "request", + "model": "CreateMediaBuyRequest", + "payload": { + "account": { + "account_id": "acct_test" + }, + "brand": { + "domain": "example.com" + }, + "idempotency_key": "0123456789abcdef0123", + "start_time": "2026-10-07T00:00:00+00:00", + "end_time": "2026-11-05T00:00:00+00:00", + "packages": [ + { + "product_id": "prod_1", + "pricing_option_id": "cpm_usd_fixed" + } + ], + "total_budget": { + "amount": 1000, + "currency": "USD" + }, + "budget_allocation": { + "mode": "seller_optimized", + "optimization_goals": [ + { + "kind": "metric", + "metric": "clicks" + } + ] + } + }, + "canonical_valid": true, + "pydantic_valid": true + }, + { + "id": "create_media_buy.valid_committed_proposal", + "tool": "create_media_buy", + "direction": "request", + "model": "CreateMediaBuyRequest", + "payload": { + "account": { + "account_id": "acct_test" + }, + "brand": { + "domain": "example.com" + }, + "idempotency_key": "0123456789abcdef0123", + "start_time": "2026-10-07T00:00:00+00:00", + "end_time": "2026-11-05T00:00:00+00:00", + "proposal_id": "prop_1", + "total_budget": { + "amount": 1000, + "currency": "USD" + } + }, + "canonical_valid": true, + "pydantic_valid": true + }, + { + "id": "create_media_buy.no_required_group_at_all", + "tool": "create_media_buy", + "direction": "request", + "model": "CreateMediaBuyRequest", + "payload": { + "account": { + "account_id": "acct_test" + }, + "brand": { + "domain": "example.com" + }, + "idempotency_key": "0123456789abcdef0123", + "start_time": "2026-10-07T00:00:00+00:00", + "end_time": "2026-11-05T00:00:00+00:00" + }, "canonical_valid": false, "pydantic_valid": false } diff --git a/tests/test_account_lifecycle.py b/tests/test_account_lifecycle.py index 7e3ae5ae0..63bfe7339 100644 --- a/tests/test_account_lifecycle.py +++ b/tests/test_account_lifecycle.py @@ -80,7 +80,9 @@ def make_client( ) client._capabilities_fetched_at = time.monotonic() client.adapter.get_products = AsyncMock( - return_value=TaskResult(status=TaskStatus.COMPLETED, data=GetProductsResponse(products=[])) + return_value=TaskResult( + status=TaskStatus.COMPLETED, data=GetProductsResponse(products=[], cache_scope="public") + ) ) return client @@ -503,7 +505,7 @@ class Seller(DecisioningPlatform): async def get_products(self, request, ctx): assert ctx.account.id == "" or ctx.account.id == "provisioned" - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") return PlatformHandler(Seller(), executor=executor, registry=InMemoryTaskRegistry()) diff --git a/tests/test_account_projection.py b/tests/test_account_projection.py index 1fa00a527..dbfc53dfc 100644 --- a/tests/test_account_projection.py +++ b/tests/test_account_projection.py @@ -13,8 +13,6 @@ from __future__ import annotations -import pytest - from adcp.decisioning import ( project_account_for_response, project_business_entity_for_response, @@ -192,4 +190,3 @@ def test_unprojected_account_leaks_bank_via_model_dump() -> None: account = _account_fixture() payload = account.model_dump(mode="json", exclude_none=True) assert "bank" in payload["billing_entity"] - pytest.skip("regression guard — see docstring") diff --git a/tests/test_agent_resolver.py b/tests/test_agent_resolver.py index ac42e37e2..4e1509d61 100644 --- a/tests/test_agent_resolver.py +++ b/tests/test_agent_resolver.py @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio +import builtins import json from typing import Any @@ -23,7 +24,7 @@ # Capture the real iterator-draining implementation before conftest's autouse # fixture substitutes the legacy mock shim. These tests exercise the wire. from adcp.protocols.a2a import A2AAdapter -from adcp.signing import agent_resolver +from adcp.signing import _resolver_protocol, agent_resolver from adcp.signing.agent_resolver import ( AgentResolution, AgentResolverError, @@ -728,3 +729,143 @@ def test_extract_brand_json_url_raises_when_field_empty_string() -> None: with pytest.raises(AgentResolverError) as exc: agent_resolver._extract_brand_json_url({"identity": {"brand_json_url": ""}}) assert exc.value.code == "brand_json_url_missing" + + +# --------------------------------------------------------------------------- +# A task-group-wrapped failure must name its sub-exception. +# +# The MCP and A2A client stacks run the capabilities fetch inside an anyio task +# group, so a connect error, a TLS failure or a strict-parse rejection reaches +# ``async_resolve_agent`` wrapped in a ``BaseExceptionGroup`` whose own ``str()`` +# is "unhandled errors in a TaskGroup (1 sub-exception)" -- a sentence that names +# neither the failure nor the agent. +# +# THIS IS A CONFORMANCE GAP, not a diagnostic inconvenience. AdCP 3.2.1 +# (dist/docs/3.2.1/building/by-layer/L1/security.mdx, the discovery-chain +# rejection table, line 1326) assigns `request_signature_capabilities_unreachable` +# the detail fields `agent_url`, `http_status`, `dns_error`, `last_attempt_at`. +# `http_status` and `dns_error` live in the sub-exception, so a seller that only +# receives the group CANNOT populate the envelope the pin describes. +# +# The classification already walks the group -- ``protocol_validation_failed`` +# does, and has since 3.10 support landed -- so these tests are about the two +# things that do not: the message, and the cause a consumer reads the facts off. + + +# Looked up rather than referenced, and rather than suppressed: the name is a +# builtin only on 3.11+, and ruff's F821 does not honour a sys.version_info +# guard for builtins, so a direct reference needs a noqa this repo ratchets. +_BaseExceptionGroup = getattr(builtins, "BaseExceptionGroup", None) +if _BaseExceptionGroup is None: # 3.10 — anyio depends on the backport there. + from exceptiongroup import BaseExceptionGroup as _BaseExceptionGroup + + +def _task_group_wrapping(leaf: BaseException) -> BaseException: + """The shape anyio raises when the body of its task group fails. + + Built through a version guard rather than skipped below 3.11: the defect + exists on 3.10 too, where anyio raises the backported group, so skipping + there would leave the oldest supported interpreter ungraded. + """ + return _BaseExceptionGroup("unhandled errors in a TaskGroup", [leaf]) + + +@pytest.fixture +def capabilities_raising(monkeypatch: pytest.MonkeyPatch): + """Make the capabilities fetch raise ``error``. + + Patched on ``_resolver_protocol``, which is where ``agent_resolver`` imports + the name from at call time -- patching the resolver's own namespace would + rename something nothing consults. + """ + + def _patch(error: BaseException): + async def raising(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + raise error + + monkeypatch.setattr(_resolver_protocol, "fetch_protocol_capabilities", raising) + + return _patch + + +async def _resolve(factory: Any) -> AgentResolverError: + with pytest.raises(AgentResolverError) as raised: + await async_resolve_agent( + "https://buyer.example.com/mcp", + agent_type="sales", + _capabilities_client_factory=factory, + ) + return raised.value + + +@pytest.mark.asyncio +async def test_a_wrapped_connect_failure_names_the_sub_exception( + patch_resolver, capabilities_raising +) -> None: + """The message carries the leaf's text, and the group's sentence is gone.""" + leaf = httpx.ConnectError("[Errno 61] Connection refused") + capabilities_raising(_task_group_wrapping(leaf)) + _, factory = patch_resolver({}) + + error = await _resolve(factory) + + assert error.code == "capabilities_unreachable" + assert "Connection refused" in error.message, ( + "the sub-exception's text reached neither the message nor the log: " + error.message + ) + assert "unhandled errors in a TaskGroup" not in error.message + + +@pytest.mark.asyncio +async def test_a_wrapped_failure_exposes_the_sub_exception_as_its_cause( + patch_resolver, capabilities_raising +) -> None: + """``__cause__`` is the LEAF, so a seller reads the pin's detail fields off it. + + Chaining the group instead would leave every consumer to walk + ``.exceptions`` itself, which is the duplication the SDK owns rather than + its adopters. + """ + leaf = httpx.ConnectError("[Errno 61] Connection refused") + group = _task_group_wrapping(leaf) + capabilities_raising(group) + _, factory = patch_resolver({}) + + error = await _resolve(factory) + + assert error.__cause__ is leaf, f"cause is {error.__cause__!r}, not the sub-exception" + assert error.__context__ is group, "the group itself must stay reachable" + + +@pytest.mark.asyncio +async def test_a_wrapped_parse_failure_is_named_as_well_as_classified( + patch_resolver, capabilities_raising +) -> None: + """The two failures a buyer confuses must differ in the MESSAGE, not only the code. + + 'unreachable' and 'reachable but not an AdCP agent' rendered identically + before this, which is what made them indistinguishable in a log. + """ + leaf = ValueError("duplicate object key 'identity'") + capabilities_raising(_task_group_wrapping(leaf)) + _, factory = patch_resolver({}) + + error = await _resolve(factory) + + assert error.code == "capabilities_invalid" + assert "duplicate object key" in error.message + assert "unhandled errors in a TaskGroup" not in error.message + + +@pytest.mark.asyncio +async def test_an_unwrapped_failure_is_unchanged(patch_resolver, capabilities_raising) -> None: + """The ordinary path keeps its behaviour: no group, so nothing to unwrap.""" + leaf = httpx.ConnectError("[Errno 61] Connection refused") + capabilities_raising(leaf) + _, factory = patch_resolver({}) + + error = await _resolve(factory) + + assert error.code == "capabilities_unreachable" + assert "Connection refused" in error.message + assert error.__cause__ is leaf diff --git a/tests/test_beta3_url_constraints.py b/tests/test_beta3_url_constraints.py index 4be0ca8d3..a361346e2 100644 --- a/tests/test_beta3_url_constraints.py +++ b/tests/test_beta3_url_constraints.py @@ -80,7 +80,12 @@ def test_beta3_secure_urls_accept_valid_https_origins() -> None: @pytest.mark.parametrize("params", [{"width": 300}, {"height": 250}]) def test_image_package_dimensions_must_cooccur(params: dict[str, int]) -> None: - with pytest.raises(ValidationError, match="width and height must co-occur"): + # ``package-request.json``'s third root conjunct: when ``format_kind`` is + # ``image`` and ``params`` is present, ``params`` carries width and height + # together or neither. It was hand-written here while the generator dropped + # the conjunct; it is now compiled from the schema, so the refusal names the + # schema node rather than a sentence this repo authored. + with pytest.raises(ValidationError, match=r"package-request\.json, root conjunct 3"): PackageRequest( product_id="product-1", pricing_option_id="price-1", @@ -90,7 +95,9 @@ def test_image_package_dimensions_must_cooccur(params: dict[str, int]) -> None: def test_package_params_require_format_kind() -> None: - with pytest.raises(ValidationError, match="params requires format_kind"): + # ``package-request.json``'s root ``dependencies``: ``params`` selects a + # vocabulary, so it implies the ``format_kind`` that names which one. + with pytest.raises(ValidationError, match=r"package-request\.json, root conjunct 0"): PackageRequest( product_id="product-1", pricing_option_id="price-1", diff --git a/tests/test_branch_classes.py b/tests/test_branch_classes.py new file mode 100644 index 000000000..b7d4f3948 --- /dev/null +++ b/tests/test_branch_classes.py @@ -0,0 +1,691 @@ +"""What an branch becomes when it is a type rather than a rule. + +``scripts/branch_classes.py`` reads one ``Branch`` record and says three things +about the class that branch becomes: which fields it must not declare, which it +must require, and what is left over for a compiled check. The first two are why +a branch class makes a combination unspellable instead of refusing it, so each +is graded here on its own, and the leftover is graded for what it KEEPS rather +than only for what it drops -- a planner that quietly swallowed an branch's nested +content would read as a stronger model and be a weaker one. + +The emitter is not wired into the generation pipeline. Four places used a split +root's public name as a BASE, and a union cannot be subclassed. The shared base +settles two of them -- ``Creative(CreativeAsset)`` and +``CreativeRepresentation(CreativeManifest)`` are generated, and the emitter +repoints them onto the base, which is graded below. The remaining two are the +hand-written create-media-buy layers (``canonical_creative`` narrows +``packages``, and ``_forward_compat`` rebuilds the model and declares a +static view of a runtime-patched field); those narrow PER BRANCH, so each needs a +class per branch and its own union, and that is the integration step. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from pydantic import TypeAdapter, ValidationError + +from adcp.types._split_root import branch_members +from scripts.branch_classes import ( + plan_branches, + render_absence_hook, + repoint_subclasses_of, + split_into_branch_classes, +) +from scripts.schema_constraints import ( + DeclaredShape, + PropSatisfies, + Restated, + ValueConst, + read_root_constraints, +) + + +def _constraints(root: dict[str, Any], fields: dict[str, str]) -> Any: + return read_root_constraints( + root, + DeclaredShape(fields=fields, nested={name: DeclaredShape() for name in fields}), + branches_render_as_classes=False, + ) + + +_TWO_ARMS: dict[str, Any] = { + "title": "Thing", + "type": "object", + "properties": {"a": {}, "b": {}, "c": {}}, + "anyOf": [ + {"required": ["a"], "not": {"required": ["b"]}}, + {"required": ["b"], "not": {"required": ["a"]}}, + ], +} + + +def test_an_branch_forbids_a_field_by_not_declaring_it() -> None: + """``not: {required: [b]}`` is the absence of the field, not a check on it.""" + first, second = plan_branches( + _constraints(_TWO_ARMS, {"a": "a", "b": "b", "c": "c"}), + "Thing", + "core/thing.json", + frozenset({"a", "b", "c"}), + ) + + assert first.absent == frozenset({"b"}) + assert first.required == frozenset({"a"}) + assert second.absent == frozenset({"a"}) + assert second.required == frozenset({"b"}) + assert first.residual == Restated("branch") and second.residual == Restated( + "branch" + ), "requiredness and absence are the whole of these branches, so no check is left" + + +def test_a_banned_pair_stays_a_check_because_neither_name_is_forbidden() -> None: + """``not: {required: [a, b]}`` forbids the COMBINATION, not either field. + + Dropping either field would refuse a document the schema accepts, so the + planner leaves the conjunct to the compiled check. ``webhook-challenge- + response`` states its ban this way. + """ + root = { + "title": "Thing", + "type": "object", + "properties": {"a": {}, "b": {}}, + "not": {"required": ["a", "b"]}, + "anyOf": [{"required": ["a"]}, {"required": ["b"]}], + } + + first, _second = plan_branches( + _constraints(root, {"a": "a", "b": "b"}), "Thing", "core/thing.json", frozenset({"a", "b"}) + ) + + assert first.absent == frozenset() + assert first.residual != Restated("branch"), "the ban must survive as a check" + + +def test_a_const_on_a_declared_field_becomes_a_literal_not_a_check() -> None: + """A discriminator is the field's type on that branch. + + The sibling ``type`` is dropped only because the ``const`` it accompanies + becomes the annotation; without that, dropping it would lose a constraint. + """ + root = { + "title": "Thing", + "type": "object", + "properties": {"status": {}, "a": {}}, + "oneOf": [ + { + "required": ["status", "a"], + "properties": {"status": {"type": "string", "const": "completed"}}, + }, + {"required": ["status"], "properties": {"status": {"const": "failed"}}}, + ], + } + + first, second = plan_branches( + _constraints(root, {"status": "status", "a": "a"}), + "Thing", + "core/thing.json", + frozenset({"status", "a"}), + ) + + assert first.literals == {"status": "completed"} + assert second.literals == {"status": "failed"} + assert first.residual == Restated("branch") + + +def test_a_nested_const_stays_a_check_because_the_field_is_not_the_subject() -> None: + """``budget_allocation.mode`` is one level down, so the branch's field set cannot carry it. + + Named as a check rather than silently dropped: this is the measured limit of + what the branch class's own shape expresses, and what the branch's compiled + validator is for. + """ + root = { + "title": "Thing", + "type": "object", + "properties": {"budget_allocation": {}, "packages": {}}, + "anyOf": [ + { + "required": ["packages"], + "properties": {"budget_allocation": {"properties": {"mode": {"const": "fixed"}}}}, + }, + {"required": ["budget_allocation"]}, + ], + } + + first, _ = plan_branches( + _constraints(root, {"budget_allocation": "budget_allocation", "packages": "packages"}), + "Thing", + "core/thing.json", + frozenset({"budget_allocation", "packages"}), + ) + + assert first.literals == {} + assert first.residual == PropSatisfies( + "budget_allocation", PropSatisfies("mode", ValueConst("fixed")) + ) + + +def test_an_absence_set_is_also_refused_on_the_raw_document() -> None: + """A field that is not declared is unspellable; a raw key still needs refusing. + + The generated models are ``extra='allow'`` where their schemas say + ``additionalProperties: true``, so pydantic would file the forbidden key + under extras and accept. The hook is what the design names as the one place + the type system alone cannot carry an branch's content. + """ + first, _ = plan_branches( + _constraints(_TWO_ARMS, {"a": "a", "b": "b", "c": "c"}), + "Thing", + "core/thing.json", + frozenset({"a", "b", "c"}), + ) + + hook = render_absence_hook(first) + + assert "_ABSENT: ClassVar[frozenset[str]] = frozenset({'b'})" in hook + assert "mode='before'" in hook + assert ( + "_c.absent(\n data, sorted(cls._ABSENT), sorted(cls._ABSENT_NULLABLE)\n )" + ) in hook + + +_FLAT_MODULE = '''from __future__ import annotations + +from pydantic import Field + +from adcp.types.base import AdCPBaseModel + + +class Thing(AdCPBaseModel): + """A flat root over both branches.""" + + a: str | None = None + b: str | None = None + c: str | None = None +''' + + +def test_the_split_module_declares_each_branch_and_a_union_over_them() -> None: + """The root's public name becomes the alias; a member is what one constructs.""" + rewritten = split_into_branch_classes( + _FLAT_MODULE, + "Thing", + _constraints(_TWO_ARMS, {"a": "a", "b": "b", "c": "c"}), + "core/thing.json", + frozenset({"a", "b", "c"}), + ) + + assert rewritten is not None + assert "class Thing(BranchDispatchMixin, AdCPBaseModel):" in rewritten + assert "class ThingA(Thing):" in rewritten + assert "class ThingB(Thing):" in rewritten + assert "ThingBranches: TypeAlias = Annotated[" in rewritten + assert " ThingA | ThingB," in rewritten + # No assertion that a module-level ``ThingBranchesAdapter`` is emitted: it is + # not, because a ``TypeAdapter`` built at import captures schema state at + # import, which ``test_sdk_has_no_module_level_type_adapter_construction`` + # forbids. The union alias above is what the emitter owes a caller; building + # an adapter over it is one line at the call site. + base = rewritten.split("class Thing(BranchDispatchMixin, AdCPBaseModel):")[1].split( + "class ThingA" + )[0] + assert "a: str | None = None" in base + assert "b: str | None = None" in base + assert "c: str | None = None" in base, "a field no branch constrains is declared once" + # The branch that requires ``a`` restates only what it changes: ``a`` loses its + # default, ``b`` becomes unpassable, and ``c`` is left to the base. + branch = rewritten.split("class ThingA(Thing):")[1].split("class ThingB")[0] + assert "a: str\n" in branch + assert "b: None = None" in branch + assert "c:" not in branch + + +def test_every_branch_is_a_subclass_of_one_base_so_the_union_is_one_type() -> None: + """The branches differ in requiredness, so they are one type -- not several. + + Measured over the eleven roots the emitter splits: every one has properties + shared by all of its branches, and eight of the eleven have branches that constrain + no property at all. A union of unrelated classes would say the branches are + different types, and nothing in the schemas says that. This is also what + gives the union a usable base: a caller can hold the shared fields, and a + class elsewhere can subclass what the branches share. + """ + from scripts.post_generate_fixes import compose_branch_module + + rewritten = compose_branch_module( + _FLAT_MODULE, + "Thing", + _constraints(_TWO_ARMS, {"a": "a", "b": "b", "c": "c"}), + "core/thing.json", + frozenset({"a", "b", "c"}), + ) + + assert rewritten is not None + namespace: dict[str, Any] = {} + exec( # noqa: S102 - the emitter's output is the subject; importing it is the test + rewritten.replace("from __future__ import annotations", ""), namespace + ) + base = namespace["Thing"] + members = branch_members(namespace["ThingBranches"]) + + assert len(members) == 2 + assert all(issubclass(member, base) for member in members) + assert {"a", "b", "c"} <= set(base.model_fields), "the base carries what the branches share" + # The adapter is built HERE rather than read out of the module, because the + # emitter no longer declares one at module level. The assertions are + # unchanged: the union parses a single-branch document into that branch and + # refuses one satisfying two, which is what "the union is one type" means. + # The adapter was only ever the instrument for asking. + adapter = TypeAdapter(namespace["ThingBranches"]) + assert adapter.validate_python({"a": "x"}).a == "x" + with pytest.raises(ValidationError): + adapter.validate_python({"a": "x", "b": "y"}) + + +def test_the_base_is_public_because_a_consumer_needs_to_name_it() -> None: + """Stated as a test so the visibility decision is recorded where it binds. + + The base exists so the branches are ONE type, and a type nobody can name or + annotate is not one -- a method that behaves per branch has to take something, + and an ``isinstance`` check has to test something. So the base is public, + and this asserts the two things that makes true of the emitted module: no + leading underscore, and a name the next pass's ``__all__`` can carry. + + The earlier version of this test asserted the opposite. It was decided on + who does ``isinstance`` against a split root TODAY, which is a measurement + of current usage of a type that did not exist yet -- it could not answer the + question. Four consumers appeared as soon as the split landed. + """ + rewritten = split_into_branch_classes( + _FLAT_MODULE, + "Thing", + _constraints(_TWO_ARMS, {"a": "a", "b": "b", "c": "c"}), + "core/thing.json", + frozenset({"a", "b", "c"}), + ) + + assert rewritten is not None + assert ( + "class Thing(BranchDispatchMixin," in rewritten + ), "the base takes the root's own name and mixes in the one shared hook" + assert not rewritten.count("class _Thing"), "nothing module-internal is emitted for the root" + + +def test_a_class_that_subclassed_the_flat_root_is_repointed_onto_the_base() -> None: + """A union cannot be subclassed, so a subclass has to name the base. + + Two generated classes do this on the pin -- ``Creative(CreativeAsset)`` and + ``CreativeRepresentation(CreativeManifest)`` -- and the rewrite is what lets + the split ship without either of them losing the fields it was reaching for. + """ + module = ( + "from ..core.thing import Thing\n" + "\n" + "\n" + "class Extended(Thing):\n" + " extra: str | None = None\n" + ) + + rewritten = repoint_subclasses_of(module, "Thing") + + assert rewritten is not None + assert "class Extended(Thing):" in rewritten + assert "from ..core.thing import Thing" in rewritten + assert repoint_subclasses_of(module, "Other") is None, "a module naming it in no base list" + + # A base nothing imports is a module that fails at import, so the rewrite + # refuses rather than emitting one. The shape it understands is a single-line + # import, which is what both sites on the pin use. + with pytest.raises(RuntimeError, match="to extend with Thing"): + repoint_subclasses_of( + "from ..core.thing import (\n Thing,\n)\n\n\nclass Extended(Thing):\n pass\n", + "Thing", + ) + + +def test_the_split_drops_a_combinator_check_the_branches_shape_already_answers() -> None: + """A flat validator left by the constraint pass must not be copied per branch. + + Copied, it would re-ask the whole combinator on every branch and would carry a + return annotation naming the union. Its decorator is what makes it run, and + an earlier version of the copier dropped decorators while keeping bodies -- + a validator that reads as one and enforces nothing. + """ + with_validator = _FLAT_MODULE + """ + @model_validator(mode='after') + def _require_schema_required_group(self) -> Thing: + if not (True): + raise ValueError('nope') + return self +""" + + rewritten = split_into_branch_classes( + with_validator, + "Thing", + _constraints(_TWO_ARMS, {"a": "a", "b": "b", "c": "c"}), + "core/thing.json", + frozenset({"a", "b", "c"}), + ) + + assert rewritten is not None + assert "raise ValueError('nope')" not in rewritten + + +def test_create_media_buys_three_branches_plan_as_the_design_measured_them() -> None: + """The worked example, read off the pinned bundle rather than restated. + + Each branch's absence set is what makes the two red parity cases that combine + branches unspellable: no class carries both ``packages`` and ``proposal_id``. + """ + from scripts.post_generate_fixes import SCHEMA_DIR, flat_root_shape + from scripts.schema_constraints import bundle_document_resolver + + relative = Path("media-buy/create-media-buy-request.json") + schema = json.loads((SCHEMA_DIR / relative).read_text()) + shape = flat_root_shape(schema, relative) + assert shape is not None + constraints = read_root_constraints( + schema, + shape, + branches_render_as_classes=False, + schema_file=str(relative), + resolve_document=bundle_document_resolver(SCHEMA_DIR), + ) + + plans = plan_branches( + constraints, "CreateMediaBuyRequest", str(relative), frozenset(shape.fields.values()) + ) + + by_name = {plan.name: plan for plan in plans} + fixed = by_name["CreateMediaBuyRequestExplicitPackagesWithFixedAllocation"] + proposal = by_name["CreateMediaBuyRequestCommittedProposalExecution"] + + assert fixed.absent == frozenset({"proposal_id"}) + assert "packages" in fixed.required + assert proposal.absent == frozenset({"packages", "budget_allocation", "frequency_cap"}) + assert proposal.required >= frozenset({"proposal_id", "total_budget"}) + assert not (fixed.absent & fixed.required), "a field cannot be both forbidden and required" + + +def test_no_plan_forbids_a_field_it_also_requires() -> None: + """Over the whole partitioning population, not just the worked example.""" + from scripts.post_generate_fixes import ( + SCHEMA_DIR, + _schema_root_class_name, + flat_root_shape, + ) + from scripts.schema_constraints import ( + bundle_document_resolver, + partitions_by_requiredness, + root_carries_constraints, + ) + + resolve = bundle_document_resolver(SCHEMA_DIR) + graded = 0 + for schema_file in sorted(SCHEMA_DIR.rglob("*.json")): + relative = schema_file.relative_to(SCHEMA_DIR) + if relative.parts[0] in ("mcp", "bundled"): + continue + try: + schema = json.loads(schema_file.read_text()) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(schema, dict) or not root_carries_constraints(schema): + continue + shape = flat_root_shape(schema, relative) + if shape is None: + continue + constraints = read_root_constraints( + schema, + shape, + branches_render_as_classes=False, + schema_file=str(relative), + resolve_document=resolve, + ) + if not constraints.combinator or not partitions_by_requiredness(constraints): + continue + # The root's NAME, which the schema states. Not the class the module + # declares: after the split there is no flat class, and the shape that + # carries the properties is called <Root>Shape -- deriving branch names + # from that spells them <Root>Shape<Branch>. + parent = _schema_root_class_name(schema) + assert parent is not None + for plan in plan_branches( + constraints, parent, str(relative), frozenset(shape.fields.values()) + ): + graded += 1 + assert not (plan.absent & plan.required), (relative, plan.name) + assert plan.required, (relative, plan.name) + + assert graded > 15, f"only {graded} branches planned" + + +def test_a_module_without_the_flat_class_is_reported_not_guessed() -> None: + assert ( + split_into_branch_classes( + _FLAT_MODULE, + "Missing", + _constraints(_TWO_ARMS, {"a": "a", "b": "b", "c": "c"}), + "core/thing.json", + frozenset({"a"}), + ) + is None + ) + + +def test_unused_import_guard() -> None: + """Keeps ``pytest`` imported for the collection-time fixtures above.""" + assert pytest is not None + + +def test_every_branch_of_every_split_root_names_its_root() -> None: + """The subclass relation, read off the SHIPPED tree rather than a re-render. + + ``isinstance(branch, shape)`` is the property the public base exists to give a + consumer, and the population it has to hold over is whichever roots the + partition prover finds -- not a list written here, which would go stale the + moment a schema refresh adds or drops a partitioning root. + + This reads the generated modules as they ship. The earlier version rendered + each root again and graded the render, which it had to while the emitter was + unwired: the committed tree still carried the flat class, and the render was + the only place the branches existed. Now the branches are in the tree, so grading a + second render would grade the renderer twice and the artifact never -- and + would not notice a later pass that edits what the emitter wrote. + """ + import ast as _ast + + from scripts.post_generate_fixes import ( + SCHEMA_DIR, + flat_root_shape, + generated_module_for, + ) + from scripts.schema_constraints import ( + bundle_document_resolver, + partitions_by_requiredness, + root_carries_constraints, + ) + from scripts.union_branch_names import branch_class_names + + resolve = bundle_document_resolver(SCHEMA_DIR) + roots = 0 + branches = 0 + for schema_file in sorted(SCHEMA_DIR.rglob("*.json")): + relative = schema_file.relative_to(SCHEMA_DIR) + if relative.parts[0] in ("mcp", "bundled"): + continue + try: + schema = json.loads(schema_file.read_text()) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(schema, dict) or not root_carries_constraints(schema): + continue + module = generated_module_for(relative) + if not module.exists(): + continue + content = module.read_text() + tree = _ast.parse(content) + bases = { + node.name: [_ast.unparse(base) for base in node.bases] + for node in tree.body + if isinstance(node, _ast.ClassDef) + } + # The root's own name is bound to a union here, so the shape is what the + # module declares for it. A root the emitter did not split has no shape. + root = _schema_root_name(schema) + # The base takes the root's own name, so a split module declares the + # root as a class and binds the union to <Root>Branches. + shape = root + if f"{root}Branches: TypeAlias" not in content: + continue + # The prover has to agree the root partitions: a shape with no proof + # behind it would mean the emitter split something it should not have. + parsed = flat_root_shape(schema, relative) + assert parsed is not None, relative + constraints = read_root_constraints( + schema, + parsed, + branches_render_as_classes=False, + schema_file=str(relative), + resolve_document=resolve, + ) + assert partitions_by_requiredness(constraints), relative + for name in branch_class_names(root, constraints.branches, str(relative)): + assert bases.get(name) == [shape], (relative, name, bases.get(name)) + branches += 1 + assert f"{root}Branches: TypeAlias" in content, relative + # And NOT a module-level adapter beside it -- asserted in the negative, + # so the emission this removed cannot come back unnoticed. + assert f"{root}BranchesAdapter" not in content, relative + roots += 1 + + assert roots >= 11, f"the prover found only {roots} split roots in the shipped tree" + assert branches >= 24, f"only {branches} branches graded" + + +def _schema_root_name(schema: dict[str, Any]) -> str: + """The class name the generator derives from a schema title.""" + from scripts.post_generate_fixes import _schema_root_class_name + + root = _schema_root_class_name(schema) + assert root is not None, f"schema carries no usable title: {schema.get('title')!r}" + return root + + +def _split_roots_from_the_prover() -> dict[str, str]: + """Every root the partition prover splits, as ``{root class: combinator}``. + + The authoritative population comes from the schemas, not from matching + shapes at runtime: a derivation over runtime shapes cannot tell a split + root's union from any other union whose members happen to share a base, + and this repo exports several of those. + """ + from scripts.post_generate_fixes import ( + SCHEMA_DIR, + _schema_root_class_name, + flat_root_shape, + ) + from scripts.schema_constraints import ( + bundle_document_resolver, + partitions_by_requiredness, + root_carries_constraints, + ) + + resolve = bundle_document_resolver(SCHEMA_DIR) + roots: dict[str, str] = {} + for schema_file in sorted(SCHEMA_DIR.rglob("*.json")): + relative = schema_file.relative_to(SCHEMA_DIR) + if relative.parts[0] in ("mcp", "bundled"): + continue + try: + schema = json.loads(schema_file.read_text()) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(schema, dict) or not root_carries_constraints(schema): + continue + shape = flat_root_shape(schema, relative) + if shape is None: + continue + constraints = read_root_constraints( + schema, + shape, + branches_render_as_classes=False, + schema_file=str(relative), + resolve_document=resolve, + ) + if not partitions_by_requiredness(constraints): + continue + root = _schema_root_class_name(schema) + assert root is not None + roots[root] = constraints.combinator or "anyOf" + return roots + + +def test_isinstance_against_the_public_base_holds_for_every_branch() -> None: + """The property the public base exists for, asserted at RUNTIME. + + Not by parsing: this imports the shipped package, walks every root the + prover splits, and checks ``issubclass`` for each branch. A parse can say + the base list names the base; only an import can say the MRO holds and that + the name resolves off ``adcp.types``. + """ + import adcp.types._generated as generated + + roots = _split_roots_from_the_prover() + assert len(roots) >= 11, f"the prover found only {len(roots)} split roots" + + branches = 0 + for root in sorted(roots): + base = getattr(generated, root, None) + assert isinstance(base, type), f"{root} does not resolve to a class" + members = branch_members(getattr(generated, f"{root}Branches", None)) + assert members, f"{root}Branches is not a union over branches" + for member in members: + assert issubclass(member, base), f"{member.__name__} is not a {root}" + # Every field the base declares is reachable on the branch, which is + # what makes a method written against the base work per branch. + assert set(base.model_fields) <= set(member.model_fields), (root, member.__name__) + branches += 1 + + assert branches >= 24, f"only {branches} branches graded" + + +def test_a_unions_visibility_follows_its_bases() -> None: + """``<Root>Branches`` is exported exactly where ``<Root>`` is. + + The base takes the root's own name, so it is exported wherever the root + was. The union is the extra name, and it earns its place only beside a + public base -- a union nobody can pair with a nameable base is a name with + nothing to annotate against. + + Both directions, because each is silent on its own. + """ + import adcp.types as public + + exported = set(public.__all__) + for root in sorted(_split_roots_from_the_prover()): + union = f"{root}Branches" + if union not in exported: + continue + # The invariant, not the provenance: whatever the public name denotes, + # the exported union's members must subclass it. That holds for the + # generated base and for a canonical base with its own branches, and it + # fails for a name bound to something the union does not cover. + base = getattr(public, root, None) + assert isinstance(base, type), f"{union} is public but {root} is not a class" + members = branch_members(getattr(public, union)) + assert members, f"{union} is not a union over branches" + for member in members: + assert issubclass( + member, base + ), f"{union} has a member {member.__name__} that is not a {root}" + + for name in sorted(n for n in exported if n.endswith("Branches")): + root = name[: -len("Branches")] + assert root in exported, f"{name} is public but {root} is not" + assert branch_members(getattr(public, name)), f"{name} is not a union over branches" + + assert any(n.endswith("Branches") for n in exported), "the derivation selected nothing" diff --git a/tests/test_branch_dispatch_on_every_path.py b/tests/test_branch_dispatch_on_every_path.py new file mode 100644 index 000000000..90e6818dc --- /dev/null +++ b/tests/test_branch_dispatch_on_every_path.py @@ -0,0 +1,507 @@ +"""A root with branches resolves to a branch on all three validation paths. + +A ``model_validate`` override reaches ONE of them. Pydantic validates a nested +field and a ``TypeAdapter`` through the core schema, so an override is invisible +on both -- and both are production paths here: most roots with branches are +nested object schemas, and ``adcp.types.validation.validate_union`` (which the +server framework calls on a handler's return value) is a ``TypeAdapter``. + +The response side shipped an override whose docstring said "runtime validators +continue to enforce the cross-field rules". It did not. Measured on the base it +guarded, with the override in place: + + validate_union(CreateContentStandardsResponse, {"totally": "wrong"}) + -> CreateContentStandardsResponse(model_extra={'totally': 'wrong'}) + +The base is ``extra="allow"``, so every field of an unrecognised document was +filed as an untyped extra and the document was accepted. This grades the +property that makes that docstring true, over every root with branches, on each +path -- so a mechanism that covers one path and not the others fails here rather +than passing three times on the one path it covers. + +It also grades the ACCEPTED document, which it did not for a long time, and that +omission is why salesagent-setka went unnoticed for 23 commits. The constructed +path WAS in the population below -- but only under ``pytest.raises``, so a +successful construction could not fail anything. ``Base(**data)`` returned a +loose base whose serializer is the union of its branches, which matches no branch +and recurses: ``model_dump(mode="json")`` raised ``Circular reference detected``. +An instrument that can only express one outcome reports clean on the other. +""" + +from __future__ import annotations + +import sys +from contextlib import contextmanager +from typing import Any + +import pytest +from pydantic import BaseModel, TypeAdapter, ValidationError + +# Importing the consolidated module is what loads every generated domain module, +# and the canonical layer is a separate import. The derivation below walks what +# is loaded, so a population derived before these imports is empty -- which is +# how the first version of this file parametrised over nothing and passed. +import adcp.types._generated # noqa: F401 +import adcp.types.canonical_creative # noqa: F401 +from adcp.types._branch_dispatch import BranchDispatchMixin +from tests.conformance import _schema_parity as parity + + +def _roots_with_branches() -> dict[str, type[BaseModel]]: + """Every shipped class that DECLARES its branches. + + Declaring ``_branch_models`` is what makes a class the base -- the hook asks + ``"_branch_models" in cls.__dict__``, not a registry -- so the same question + the hook asks is the question that selects the population. All three sources + are in it: a split root the branch emitter compiled, a response base whose + numbered branches the generator wrote, and the canonical create request, + which re-declares the method to dispatch to canonical branches. + + Keyed by module and class, not by exported name. Deriving over + ``adcp.types.__all__`` found 11 of these, because ``CreativeAsset`` and + ``CreativeManifest`` resolve to canonical REFINEMENTS of a single branch -- + which inherit the method, correctly keep their own shape, and are not bases + over branches at all. + """ + walked: dict[str, type[BaseModel]] = {} + for module in list(sys.modules.values()): + if module is None or not getattr(module, "__name__", "").startswith("adcp.types"): + continue + for value in vars(module).values(): + if ( + isinstance(value, type) + and issubclass(value, BaseModel) + and "_branch_models" in value.__dict__ + ): + walked[f"{value.__module__}.{value.__name__}"] = value + return walked + + +def _no_branch_accepts_the_empty_document(root: type[BaseModel]) -> bool: + """Whether ``{}`` satisfies no branch of this root. + + The precondition of the probes below, asserted rather than assumed: a root + with a branch that requires nothing accepts ``{}``, and expecting a refusal + of it would be expecting the wrong thing. Three derived probes in this + series passed vacuously because a precondition like this one went unchecked. + """ + branches = root._branch_models() # type: ignore[attr-defined] + return bool(branches) and all( + any(field.is_required() for field in branch.model_fields.values()) for branch in branches + ) + + +def _graded_roots() -> dict[str, type[BaseModel]]: + return { + name: root + for name, root in _roots_with_branches().items() + if _no_branch_accepts_the_empty_document(root) + } + + +def _keywords(error: ValidationError) -> set[str]: + return {entry["type"] for entry in error.errors()} + + +def test_a_document_satisfying_no_branch_is_refused_on_all_three_paths() -> None: + """By name, nested in another model, and through a ``TypeAdapter``. + + Direct construction is graded alongside them, because it is the same core + schema and because the loose base it used to produce is what let a root + accept by construction the same empty document it refused by validation. + + The refusal names the root's own combinator on every path: ``core/error.json`` + requires each ``issues[]`` entry to carry a keyword from the JSON Schema + vocabulary, and the ``missing`` pydantic reports is not in it. + """ + graded = _graded_roots() + assert graded, "the derivation selected no root to grade" + + for name, root in sorted(graded.items()): + combinator = root._branch_combinator() # type: ignore[attr-defined] + assert combinator in {"anyOf", "oneOf"}, (name, combinator) + + class Holder(BaseModel): + value: root # type: ignore[valid-type] + + paths = { + "by name": lambda root=root: root.model_validate({}), + "nested": lambda holder=Holder: holder.model_validate({"value": {}}), + "TypeAdapter": lambda root=root: TypeAdapter(root).validate_python({}), + "constructed": root, + } + for path, call in paths.items(): + with pytest.raises(ValidationError) as caught: + call() + assert combinator in _keywords(caught.value), ( + f"{name} on the {path} path refused with " + f"{sorted(_keywords(caught.value))}, not {combinator!r}" + ) + + +def test_the_population_covers_every_source_of_branches() -> None: + """A derivation that matches nothing is green, so the floor is asserted. + + Thirteen split roots the branch emitter compiles, the five public response + names whose numbered branches the generator writes, and the canonical create + request. The floor is their sum; it is a floor because a schema refresh can + only add roots that partition. + """ + roots = _roots_with_branches() + assert len(roots) >= 19, f"the derivation found only {len(roots)} roots with branches" + + graded = _graded_roots() + # Measured: all 19. A root that stopped refusing it would mean some branch + # requires nothing, which is a branch that accepts any document at all. + assert len(graded) == len( + roots + ), f"{sorted(set(roots) - set(graded))} have a branch that requires nothing" + + # The five public response names are what this change adds to the + # population. Named, not counted: a count of graded names ending in + # "Response" is already satisfied by the split roots that happen to be + # responses, so it would stay green with all five of these missing. The + # generator's own list of them is hand-written, so this is auditable against + # it rather than derived from the same place. + for module, name in ( + ("compliance.comply_test_controller_response", "ComplyTestControllerResponse"), + ("content_standards.create_content_standards_response", "CreateContentStandardsResponse"), + ("content_standards.list_content_standards_response", "ListContentStandardsResponse"), + ("content_standards.update_content_standards_response", "UpdateContentStandardsResponse"), + ("account.sync_governance_response", "SyncGovernanceResponse"), + ): + key = f"adcp.types.domains.{module}.{name}" + assert key in graded, f"{name} is not dispatching through its branches" + + # The canonical create request runs on the same mechanism. It used to carry + # a second copy of the hook, keyed on class identity through two module + # globals, and its own ``_branch_models`` -- inherited, not declared -- + # reported the GENERATED branches while that schema was built from the + # canonical ones. + canonical = "adcp.types.canonical_creative.CreateMediaBuyRequest" + assert canonical in roots + assert [branch.__module__ for branch in roots[canonical]._branch_models()] == [ # type: ignore[attr-defined] + "adcp.types.canonical_creative" + ] * 3 + + +def test_a_branch_keeps_its_own_shape_rather_than_its_siblings() -> None: + """A branch inherits the hook and must not validate as the union. + + The deleted override tested ``cls in arms`` for this, which is correct for + an override and silently wrong for a core-schema hook: membership admits + both "is a branch" and "is an unrelated subclass of the base", and only the + second matters once the schema itself is being replaced. The hook instead + asks whether the class DECLARES the method, which separates them. + + Graded by what a refusal SAYS. A branch asked to validate ``{}`` reports the + fields IT requires; a branch that had picked up the union's schema would + report the combinator, having tried every sibling. + """ + graded = _graded_roots() + assert graded, "the derivation selected no root to grade" + + branches = 0 + for name, root in sorted(graded.items()): + combinator = root._branch_combinator() # type: ignore[attr-defined] + for branch in root._branch_models(): # type: ignore[attr-defined] + assert "_branch_models" not in branch.__dict__, (name, branch.__name__) + with pytest.raises(ValidationError) as caught: + branch.model_validate({}) + assert combinator not in _keywords(caught.value), ( + f"{branch.__name__} refused with {combinator!r}, so it validates as " + "the union rather than as itself" + ) + branches += 1 + + assert branches >= 49, f"only {branches} branches graded" + + +def test_ignoring_unknown_fields_cannot_make_a_branch_vacuously_satisfiable() -> None: + """``extra="ignore"`` must not turn an unrecognised document into a valid one. + + Most of these models ignore extras: ``AdCPBaseModel`` is + ``extra="ignore"``, and the generated response bases are ``extra="allow"`` + where their schemas say ``additionalProperties: true``. Under either + setting, a branch that requires NOTHING matches every document -- pydantic + drops or files the unknown keys and the branch is satisfied -- so one such + branch would make its root accept anything while still looking dispatched. + + Asserted on the live models rather than reasoned about, because the property + depends on the requiredness each branch ended up with after every pass that + edits it, and a redeclaration that re-added a default would take it away. + """ + roots = _roots_with_branches() + assert roots, "the derivation selected no root" + + ignoring = { + name: root for name, root in roots.items() if root.model_config.get("extra") != "forbid" + } + assert ignoring, "no root with branches tolerates an unknown field" + + for name, root in sorted(ignoring.items()): + for branch in root._branch_models(): # type: ignore[attr-defined] + required = {field for field, info in branch.model_fields.items() if info.is_required()} + assert required, f"{name}: {branch.__name__} requires nothing, so it matches anything" + + # And the whole root, end to end: a document of nothing but unknown keys + # is refused rather than emptied into a branch. + with pytest.raises(ValidationError): + root.model_validate({"totally": "wrong", "unknown": [1, 2]}) + + +class _LocalDefs: + """A ``$ref`` resolver over one pydantic schema's own ``$defs``. + + ``parity.instances`` needs a resolver; a model's ``model_json_schema()`` + keeps every nested model under ``$defs`` and refers to it locally, so + resolving is a dict lookup. Reusing that synthesizer rather than writing a + second one keeps one owner for "a document this schema accepts". + """ + + def __init__(self, schema: dict[str, Any]) -> None: + self._defs = schema.get("$defs", {}) + + @contextmanager + def resolving(self, ref: str) -> Any: + yield self._defs[ref.rsplit("/", 1)[-1]] + + +def _accepted_document(root: type[BaseModel]) -> dict[str, Any] | None: + """A document ``root`` accepts, synthesized from its schema, or ``None``. + + Candidates come from the root's schema and then from each branch's, and the + MODEL is the oracle for which candidate is valid -- the same + synthesize-then-ask shape ``tests/conformance`` uses, rather than a + hand-written payload per root that would go stale against its schema. + """ + for source in (root, *root._branch_models()): # type: ignore[attr-defined] + schema = source.model_json_schema(mode="validation") + try: + candidates = parity.instances(schema, _LocalDefs(schema)) + except Exception: + continue + for candidate in candidates: + if not isinstance(candidate, dict): + continue + try: + root.model_validate(candidate) + except ValidationError: + continue + return candidate + return None + + +#: ``VerifyBrandClaimRequest`` is the one root the synthesizer cannot satisfy: its +#: ``claim`` property is a nested object whose own requirements are stated outside +#: its ``required`` list, so every candidate arrives as ``claim: {}`` and the +#: required-group validator refuses it. Named rather than silently absent, and the +#: floor below is asserted so the set cannot quietly grow. +_WITHOUT_A_SYNTHESIZABLE_DOCUMENT = frozenset( + {"adcp.types.domains.brand.verify_brand_claim_request.VerifyBrandClaimRequest"} +) + + +def test_every_root_with_branches_dispatches_its_construction() -> None: + """The mixin's ``__new__`` is what every root resolves for construction. + + Exhaustive over all 19, and it costs nothing: a root that lost the dispatch + -- by a subclass defining its own ``__new__``, or by the mixin's being + deleted -- fails here even where no document can be synthesized for it. + """ + roots = _roots_with_branches() + assert roots, "the derivation selected no root" + + for name, root in sorted(roots.items()): + assert root.__new__ is BranchDispatchMixin.__new__, ( + f"{name} does not construct through the dispatch, so Base(**data) " + "returns the base rather than the branch" + ) + + +def test_an_accepted_document_constructs_as_a_branch_and_serializes() -> None: + """The other half of the constructed path: what a SUCCESS produces. + + Two assertions per root, and the second is the one salesagent-setka needed. + A base instance is not merely loosely typed -- its serializer is the union of + its branches, so it cannot be serialized at all on the JSON paths. + """ + graded = { + name: root + for name, root in _roots_with_branches().items() + if name not in _WITHOUT_A_SYNTHESIZABLE_DOCUMENT + } + # A derivation that matches nothing is green, so the floor is asserted, and + # the root whose crash prompted this is named rather than counted. + assert len(graded) >= 18, f"only {len(graded)} roots left after the exclusion" + assert "adcp.types.canonical_creative.CreateMediaBuyRequest" in graded + + for name, root in sorted(graded.items()): + document = _accepted_document(root) + assert document is not None, ( + f"{name}: no document could be synthesized, so this root is graded " + "vacuously -- add it to _WITHOUT_A_SYNTHESIZABLE_DOCUMENT with the reason" + ) + branches = root._branch_models() # type: ignore[attr-defined] + + constructed = root(**document) + assert type(constructed) in branches, ( + f"{name} constructed as {type(constructed).__name__}, which is not one " + f"of its branches -- Base(**data) is not dispatching" + ) + # model_validate already resolves to a branch; asserting both agree is + # what makes "the root's name resolves to the branch" one property rather + # than two that can drift apart. + assert type(root.model_validate(document)) is type(constructed) + + for mode in ("python", "json"): + constructed.model_dump(mode=mode) + constructed.model_dump_json() + + +#: An error type that NAMES A KEY THE DOCUMENT DOES NOT CARRY. Its last ``loc`` +#: element is the absent key, so only the prefix can be resolved against the +#: submitted document. Every other type points at something that is there. +_NAMES_AN_ABSENT_KEY = frozenset({"missing"}) + + +def _copy_with(document: Any, path: tuple[Any, ...], value: Any) -> Any: + """``document`` with ``path`` replaced by ``value``, copied along the way.""" + if not path: + return value + head, rest = path[0], path[1:] + if isinstance(document, dict): + return {**document, head: _copy_with(document[head], rest, value)} + return [ + _copy_with(item, rest, value) if index == head else item + for index, item in enumerate(document) + ] + + +def _leaf_paths(document: Any, prefix: tuple[Any, ...] = ()) -> list[tuple[Any, ...]]: + """Every scalar position in ``document``, deepest first. + + Deepest first because depth is where the defect this grades lived: a + one-element path cannot tell a prepended element from the key itself in + every spelling, and a path three levels down can. + + An EMPTY container is a leaf: three of these roots accept a document whose + only value is ``[]`` (``{"accounts": []}``), and descending into it yields + no position at all -- which left them with nothing to corrupt and dropped + them out of the grading silently. + """ + if isinstance(document, (dict, list)) and not document: + return [prefix] + if isinstance(document, dict): + found = [ + path for key, value in document.items() for path in _leaf_paths(value, (*prefix, key)) + ] + elif isinstance(document, list): + found = [ + path + for index, value in enumerate(document) + for path in _leaf_paths(value, (*prefix, index)) + ] + else: + return [prefix] + return sorted(found, key=len, reverse=True) + + +def _addresses(document: Any, loc: tuple[Any, ...], error_type: str) -> bool: + """Whether ``loc`` OPENS with a path INTO ``document``. + + Resolution, not spelling. A synthetic element is a plain string + indistinguishable from a legitimate key by inspection -- a union member is + spelled by its class name, and a wrapped one by a core-schema expression -- + so a predicate that recognised them would be a list of spellings that the + next spelling escapes. Walking the submitted document cannot be escaped: an + element that is not a key or an index it carries does not resolve. + + THE FIRST ELEMENT, which is where the label of a split root's own union sits + by construction: the hook installs that union AT the root, so its choice + label is prepended to every error the root reports and lands at position + zero. A deeper element can also fail to resolve, from a union that is a + FIELD's declared type -- measured as ``/account/AccountReference1/account_id`` + on the canonical create request, with this root cause already fixed. That is + salesagent-8qela.46, and it is a different question: the dotted + ``error.field`` a pydantic loc reaches a buyer as is graded by + ``test_genuine_union_keeps_both_variant_errors_and_locations`` to KEEP a + genuine union's member name, so widening this to the whole path is that + issue's acceptance test rather than this one's assertion. + """ + opening = loc[:-1] if error_type in _NAMES_AN_ABSENT_KEY else loc + if not opening: + return True + element = opening[0] + if isinstance(document, dict): + return element in document + return isinstance(element, int) and isinstance(document, list) and element < len(document) + + +def test_a_refusal_points_at_a_path_in_the_submitted_document() -> None: + """Every ``loc`` a root with branches reports resolves against the document. + + A pydantic union does not report a document path: it prefixes every error + with the label of the choice that produced it, which for these roots is a + class this package generated, and for a branch carrying a compiled validator + is pydantic's own spelling of the wrapper -- + ``function-after[_require_schema_required_group(), <Branch>]``. That element + reaches a buyer: the three places that build an INVALID_REQUEST join a loc + into ``error.field``, and ``core/error.json`` specifies ``issues[].pointer`` + as an RFC 6901 JSON Pointer to the offending field in the request payload. + A buyer resolving either against what it sent gets nothing. + + The element's source is the UNION, not the validator. Measured: a union of + two plain models reports ``('PlainA', 'packages', 0, 'geo', 0)`` for a + document error at ``/packages/0/geo/0``, the same models carrying a + model-level validator report the ``function-after`` spelling, and a FLAT + model with that validator adds nothing at all. So this grades the union + every root with branches installs, over all of them, rather than the one + root whose pointer was reported. + + The document is the one the sibling tests synthesize, with its DEEPEST leaf + corrupted, so a resolvable path here is a path through a real request rather + than through a one-key probe. + """ + graded = { + name: root + for name, root in _roots_with_branches().items() + if name not in _WITHOUT_A_SYNTHESIZABLE_DOCUMENT + } + # A derivation that matches nothing is green, so the floor is asserted and + # the root whose pointer carried a validator's name is named. + assert len(graded) >= 18, f"only {len(graded)} roots left after the exclusion" + assert "adcp.types.canonical_creative.CreateMediaBuyRequest" in graded + + refused = 0 + for name, root in sorted(graded.items()): + document = _accepted_document(root) + assert document is not None, ( + f"{name}: no document could be synthesized, so this root is graded " + "vacuously -- add it to _WITHOUT_A_SYNTHESIZABLE_DOCUMENT with the reason" + ) + # A value no field accepts, at the deepest position that produces a + # refusal: a leaf typed ``Any`` accepts anything, so which position + # refuses is a fact about the schema and is found rather than assumed. + entries: list[Any] = [] + for path in _leaf_paths(document): + corrupted = _copy_with(document, path, object()) + try: + root.model_validate(corrupted) + except ValidationError as exc: + entries = exc.errors() + break + if not entries: + continue + refused += 1 + for entry in entries: + loc = tuple(entry["loc"]) + assert _addresses(corrupted, loc, entry["type"]), ( + f"{name}: issues[].pointer would be " + f"/{'/'.join(str(element) for element in loc)}, which does not " + f"resolve against the submitted document -- its keys are " + f"{sorted(corrupted)}. Corrupted {path}, error {entry['type']}." + ) + # The corruption has to actually refuse, or every assertion above is skipped + # by a loop that never runs. + assert refused >= 18, f"only {refused} of {len(graded)} roots refused the corrupted document" diff --git a/tests/test_bundled_schema_matches_generated_assets.py b/tests/test_bundled_schema_matches_generated_assets.py new file mode 100644 index 000000000..f59a632f1 --- /dev/null +++ b/tests/test_bundled_schema_matches_generated_assets.py @@ -0,0 +1,302 @@ +"""Every asset type the bundled schema declares must be ROUTABLE by the models. + +WHY THE OBVIOUS COMPARISON IS THE WRONG ONE. The first version of this file +compared the bundled schema's ``asset_type`` consts against the generated +classes' ``asset_type`` defaults. That comparison passes on this tree, and it +would have passed at EVERY upstream release point including the broken ones, +because the generated classes always covered the vocabulary. The generated +artifact — ``adcp/types/domains/core/format.py`` — reads 42 ``asset_type: +Literal`` declarations (21 distinct, two arms) at v9.0.0-beta.1 through the +beta.4 release commit. Codegen was never the defect. + +THE DEFECT IS A THIRD SIDE: WHAT THE MODELS ACTUALLY ROUTE. ``types/aliases.py`` +carries two HAND-WRITTEN unions, and ``types/_forward_compat.py`` patches them +over the correct generated annotations at import. Upstream those unions carry 14 +real arms individually and 12 in-group against the schema's 21, so seven +declared asset types (``card``, ``daast_tracker``, ``display_tag``, +``pixel_tracker``, ``published_post``, ``vast_tracker``, ``zip``) and nine +in-group fall through to the unknown arm. A schema-valid document deserializes as +``UnknownFormatAsset``. + +SO THE ASSERTION IS: schema vocabulary == what ``Format.assets`` routes AFTER +forward-compat has been applied. Not what the generated classes declare, which is +correct and always was. + +THE PATCH IS APPLIED BY A MODULE BODY, AND WHO TRIGGERS IT IS NOT FULLY PINNED. +``adcp.types._forward_compat`` calls ``_apply_forward_compat()`` when imported, so +this module imports it explicitly to guarantee the patched state is the one +graded. What is NOT established is that the explicit import is the only route: +measured on the 9.0.0b2 wheel, the walk reads 14 routable with or without it, so +something in the import chain already applies it there. The explicit import is +therefore a guarantee rather than the mechanism, and a reader should not take it +as evidence about which import arms the patch. + +FIRE-PROBED IN BOTH DIRECTIONS ON REAL ARTIFACTS, not on a mutation. On this +fork it passes — schema 21, individual 21, group 21 — because ``0b8b194ab`` +generates both unions and derives the vocabulary they route by. Run against the +``9.0.0b2+prs1435.1415.1447.1456`` wheel it FAILS, reading 14 individually and 12 +in-group and naming exactly ``card``, ``daast_tracker``, ``display_tag``, +``pixel_tracker``, ``published_post``, ``vast_tracker``, ``zip`` (plus ``brief`` +and ``catalog`` in-group). + +That pair is what the first version of this file could not produce. Its probe +renamed a const in the bundled schema and watched the comparison go red, which +proves the comparison WORKS and says nothing about whether it is pointed at the +defect — the first version passed on the broken wheel too. + +``routed_by`` is taken from the measurement in acp-branch-runs/repro, which is +where the walk was worked out. Its schema reader is not: that one resolves +``adcp.__file__ / "_schemas"``, which exists only in an installed wheel, so it +cannot run from an editable checkout. The reader here goes through the SDK's own +``_resolve_schema_root``, which prefers the packaged copy and falls back to the +repo layout — the same bytes in either case. + +THE SECOND DIRECTION, AND WHY THE ARM COMPARISON ABOVE IS NOT ENOUGH. Everything +described so far compares SETS: the schema's consts against the arms the union +carries. That caught the real defect, and the b2-wheel run above proves it. It is +still blind in one direction, because a callable ``Discriminator`` chooses the arm +and the vocabulary it chooses by need not be the arm set. Upstream holds that +vocabulary in two further hand-maintained frozensets, so a fix replacing only the +unions there changes routing not at all. This fork derives both from the same +emitted branches, which is why it passes on both counts — but nothing was +GRADING the second count. + +So ``_parses_to`` asks pydantic to deserialize one schema-valid document per +declared type and reports the class it produced. Fire-probed as a pair, on this +tree, by deleting ``'pixel_tracker'`` from ``_KNOWN_BASEINDIVIDUALASSET_TYPES`` in +the generated ``core/format.py`` and leaving all 21 arms intact: + + test_every_declared_asset_type_is_routable[individual] PASSED + test_every_declared_asset_type_parses_to_a_typed_class[individual] FAILED + -> ['pixel_tracker'] + +That pair is the justification for the second direction existing: the arm +comparison cannot see a vocabulary held outside the arms, and this can. + +The control is load-bearing and is its own test. An instrument that refuses every +document reports "nothing downgraded" exactly as cleanly as one where everything +routes, and the first two drafts of ``_DOCUMENTS`` here did refuse all 21 rows, +control included. Removing the required ``asset_id`` from the document reproduces +that state, and both the control and the parse assertion go red naming it, rather +than one of them passing quietly. + +Refs: salesagent-8qela.168, salesagent-raqvy +""" + +from __future__ import annotations + +import json +import typing +from typing import Any + +import pytest + +# THE IMPORT THAT ARMS THE PATCH. Not a convenience and not reorderable: without +# it the generated annotations measure a correct 21/21 and this file grades +# nothing. Imported for its module-body side effect, hence the noqa. +import adcp.types._forward_compat # noqa: F401 +from adcp.types.aliases import RepeatableAssetGroup +from adcp.types.domains.core.format import Format +from adcp.validation.schema_loader import _resolve_schema_root + + +def _declared_by_schema( + doc: Any, found: set[str] | None = None, key: str | None = None +) -> set[str]: + """Every ``asset_type`` ``const`` anywhere under ``doc``. + + Whole-subtree rather than a fixed path: ``core/format.json`` discriminates on + ``item_type`` first and ``asset_type`` second, and the two arms nest to + DIFFERENT depths, so a fixed-depth walk derives the full set for one arm and + the empty set for the other. + """ + found = set() if found is None else found + if isinstance(doc, dict): + if key == "asset_type" and isinstance(doc.get("const"), str): + found.add(doc["const"]) + for inner_key, value in doc.items(): + _declared_by_schema(value, found, inner_key) + elif isinstance(doc, list): + for value in doc: + _declared_by_schema(value, found, key) + return found + + +def _routed_by(model: type, field: str = "assets") -> set[str]: + """Every ``asset_type`` the union on ``model.<field>`` carries an arm for. + + Walks the annotation rather than the class list, because the annotation is + what forward-compat replaces. ``Annotated`` is unwrapped to its first + argument so a discriminated union carrying field metadata is followed rather + than treated as a leaf. + + THIS IS THE NECESSARY HALF, NOT THE SUFFICIENT ONE, and an earlier version of + this docstring claimed otherwise — that the annotation is "what pydantic + dispatches on". With a CALLABLE ``Discriminator`` pydantic calls the + discriminator first and selects an arm by the tag it returns, so a vocabulary + held somewhere other than the arms can route a declared type to the fallback + while every arm for it exists. Upstream holds exactly that: two further + hand-maintained frozensets feed the callable discriminator, which is why + replacing only the unions there changes routing not at all. An arm set alone + cannot see that, so ``_parses_to`` below asks a document instead. + """ + out: set[str] = set() + stack = [model.model_fields[field].annotation] + while stack: + node = stack.pop() + args = typing.get_args(node) + if typing.get_origin(node) is typing.Annotated: + stack.append(args[0]) + continue + if args: + stack.extend(args) + continue + declared = getattr(node, "model_fields", {}).get("asset_type") + if declared is not None and isinstance(declared.default, str): + out.add(declared.default) + return out + + +@pytest.fixture(scope="module") +def declared() -> set[str]: + """The schema's vocabulary, from the bundle the wheel ships.""" + root = _resolve_schema_root() + assert root is not None, "no bundled schema tree resolved for the SDK's pinned version" + path = root.core / "format.json" + if not path.exists(): + path = root.bundled / "core" / "format.json" + assert path.exists(), f"bundled core/format.json not found under {root.root}" + return _declared_by_schema(json.loads(path.read_text())) + + +def test_the_schema_declares_asset_types(declared: set[str]) -> None: + """The authority side is non-empty, so the comparisons below grade something. + + An empty authority compares cleanly against any subject, including an empty + one, so this is the assertion that makes the others mean anything. + """ + assert declared, "the bundled schema declared no asset_type const" + + +@pytest.mark.parametrize( + ("label", "model"), + [("Format.assets", Format), ("RepeatableAssetGroup.assets", RepeatableAssetGroup)], + ids=["individual", "repeatable_group"], +) +def test_the_models_route_asset_types(label: str, model: type) -> None: + """Each subject side is non-empty too, for the same reason in reverse.""" + assert _routed_by(model), f"{label} routes no asset_type; the walk found nothing to compare" + + +@pytest.mark.parametrize( + ("label", "model"), + [("Format.assets", Format), ("RepeatableAssetGroup.assets", RepeatableAssetGroup)], + ids=["individual", "repeatable_group"], +) +def test_every_declared_asset_type_is_routable(declared: set[str], label: str, model: type) -> None: + """The assertion the shipped wheel could not make about itself. + + One direction: the schema is the authority, so a type it declares MUST be + routable. A routable type with no const would be a different question and is + not asserted here. + """ + unroutable = sorted(declared - _routed_by(model)) + assert not unroutable, ( + f"{len(unroutable)} of {len(declared)} asset types the bundled " + f"core/format.json declares cannot be routed by {label}, so a schema-valid " + f"document deserializes them as UnknownFormatAsset: {unroutable}" + ) + + +#: Minimal schema-valid documents, one per routing path, with the ``asset_type`` +#: left to the caller. Both shapes are lifted from the passing cases in +#: ``test_forward_compat_assets.py`` rather than invented: a document missing +#: ``item_type``, or carrying ``asset_id`` where a group wants ``asset_group_id``, +#: is REFUSED, and a refusal on every row reads as "nothing downgraded". +_DOCUMENTS: dict[str, typing.Callable[[str], tuple[type, dict[str, Any]]]] = { + "Format.assets": lambda asset_type: ( + Format, + { + "format_id": {"agent_url": "https://test.example.com", "id": "parses_to"}, + "name": "parses_to", + "assets": [ + { + "asset_id": "slot", + "asset_type": asset_type, + "item_type": "individual", + "required": True, + } + ], + }, + ), + "RepeatableAssetGroup.assets": lambda asset_type: ( + RepeatableAssetGroup, + { + "asset_group_id": "slide", + "item_type": "repeatable_group", + "required": True, + "min_count": 1, + "max_count": 5, + "assets": [{"asset_id": "slot", "asset_type": asset_type, "required": True}], + }, + ), +} + + +def _parses_to(label: str, asset_type: str) -> str: + """The class name a schema-valid document for ``asset_type`` lands in. + + A refusal is returned as a value rather than raised, because "this document + was rejected" and "this type was downgraded" are different answers and the + control below has to be able to tell them apart. + """ + model, document = _DOCUMENTS[label](asset_type) + try: + parsed = model.model_validate(document) + except Exception as error: # noqa: BLE001 — a refusal is a result here + return f"<refused: {type(error).__name__}>" + assets = parsed.assets + assert assets, f"{label}: the parsed model carries no assets to inspect" + return type(assets[0]).__name__ + + +@pytest.mark.parametrize("label", sorted(_DOCUMENTS), ids=["individual", "repeatable_group"]) +def test_an_undeclared_asset_type_reaches_the_fallback(label: str) -> None: + """The control, and it must run before any row of the next test is believed. + + An instrument that REFUSES every document reports "nothing downgraded" just + as cleanly as one where everything routes, so the next test's verdict is only + readable once this one has shown the fallback being reached on a type the + schema does not declare. Both of the first two drafts of ``_DOCUMENTS`` + refused all 21 rows, control included. + """ + landed = _parses_to(label, "x_future_asset_type_no_schema_declares") + assert "Unknown" in landed, ( + f"{label}: an undeclared asset_type landed in {landed!r} rather than the " + "Unknown fallback. The document shape is wrong, so this file is measuring " + "acceptance and not routing — fix it before reading the parse assertions." + ) + + +@pytest.mark.parametrize("label", sorted(_DOCUMENTS), ids=["individual", "repeatable_group"]) +def test_every_declared_asset_type_parses_to_a_typed_class(declared: set[str], label: str) -> None: + """The sufficient half: a real document reaches its class, not just an arm. + + ``test_every_declared_asset_type_is_routable`` compares arm sets, which is + blind to a vocabulary held outside the arms — see ``_routed_by``. This asks + pydantic to deserialize one document per declared type and reports the class + it actually produced, so a discriminator that answers ``_unknown`` ahead of a + present arm is a failure here and invisible there. + """ + downgraded = sorted(a for a in declared if "Unknown" in _parses_to(label, a)) + refused = sorted(a for a in declared if "refused" in _parses_to(label, a)) + assert not refused, ( + f"{label}: {len(refused)} of {len(declared)} declared asset types produced a " + f"schema-INVALID document, so they were graded on nothing: {refused}" + ) + assert not downgraded, ( + f"{label}: {len(downgraded)} of {len(declared)} asset types the bundled " + f"core/format.json declares deserialize to the Unknown fallback even though " + f"the union carries an arm for them, which means the routing vocabulary and " + f"the arm set disagree: {downgraded}" + ) diff --git a/tests/test_canonical_boundary_serialization.py b/tests/test_canonical_boundary_serialization.py index 0e1d0fa88..c243a1fe4 100644 --- a/tests/test_canonical_boundary_serialization.py +++ b/tests/test_canonical_boundary_serialization.py @@ -61,7 +61,16 @@ def counted(value: Any, **kwargs: Any) -> Any: elif method == "adapter_json": result = json.loads(TypeAdapter(Catalog).dump_json(catalog, exclude_none=True)) else: - result = Envelope(catalog=catalog).model_dump(exclude_none=True)["catalog"] + # ``model_construct`` on the parent too, for the same reason the fixture + # uses it: this test's subject is how many times the strip runs at a + # root, and the fixture is a document validation REFUSES by design. + # ``Envelope(catalog=catalog)`` would validate it and be refused by the + # wrap validator on the boundary base -- correctly, since the refusal + # fires on this document at top level as well as nested. Routing the + # fixture through validation to reach a serializer assertion asks the + # model to accept what it was built to smuggle past. + envelope = Envelope.model_construct(catalog=catalog) + result = envelope.model_dump(exclude_none=True)["catalog"] # Two declarations and their catalog each enforce their own boundary. # The public dump methods must not add a fourth pass over the full tree. diff --git a/tests/test_canonical_creatives_rc3.py b/tests/test_canonical_creatives_rc3.py index f0dd8354c..c2bda6e21 100644 --- a/tests/test_canonical_creatives_rc3.py +++ b/tests/test_canonical_creatives_rc3.py @@ -135,7 +135,14 @@ class Container(BaseModel): declaration: adcp.Format assert _legacy_value_paths(TypeAdapter(adcp.Format).dump_python(declaration)) == [] - assert _legacy_value_paths(Container(declaration=declaration).model_dump()) == [] + # ``model_construct`` on the container, matching the fixture above: the + # first assertion in this test is that validation REFUSES this very shape, + # so ``Container(declaration=declaration)`` would validate the smuggled + # declaration and be refused by that same rule. The subject here is what a + # dump can emit, not what validation accepts. + assert ( + _legacy_value_paths(Container.model_construct(declaration=declaration).model_dump()) == [] + ) def test_primary_dump_strips_extension_bearing_tuple_under_neutral_extra_name() -> None: diff --git a/tests/test_canonical_formats_compatibility.py b/tests/test_canonical_formats_compatibility.py index ffd1ee144..29ba34857 100644 --- a/tests/test_canonical_formats_compatibility.py +++ b/tests/test_canonical_formats_compatibility.py @@ -16,6 +16,7 @@ ) from adcp.types import FormatCard from adcp.types.legacy import LegacyFormatId as FormatId +from adcp.validation.schema_loader import get_named_schema_document def test_upgrade_legacy_display_size_to_parameterized_canonical_format_id() -> None: @@ -186,3 +187,98 @@ def test_projection_accepts_a_reference_taken_off_a_model_field() -> None: assert projected.diagnostic is None assert projected.declaration is not None + + +def _schema_parameters() -> frozenset[str]: + """The parameters ``core/format-id.json`` declares beyond the identity pair. + + Read from the bundled schema rather than from the generated model, so the + guard below fails when a helper stops comparing a parameter the *spec* + declares — not merely when it disagrees with the model it was written + against. + """ + document = get_named_schema_document("core/format-id.json") + assert document is not None, "core/format-id.json is absent from the bundled schema tree" + return frozenset(document["properties"]) - frozenset(document["required"]) + + +#: One pair of conflicting, dependency-legal values per declared parameter. +#: ``pixel_ratio`` needs ``width``/``height`` present, which ``_BASE`` supplies. +_CONFLICTING_VALUES: dict[str, tuple[object, object]] = { + "width": (300, 728), + "height": (250, 90), + "duration_ms": (15000, 30000), + "pixel_ratio": (2, 3), +} + +_BASE = { + "agent_url": CANONICAL_CREATIVE_AGENT_URL, + "id": "display_image", + "width": 300, + "height": 250, +} + + +def test_the_conflict_table_covers_every_parameter_the_schema_declares() -> None: + """Keeps the parametrized guard below honest as the schema grows. + + A new parameter in ``core/format-id.json`` fails here until it is given a + conflicting pair, which then puts it under the comparison guard. + """ + assert set(_CONFLICTING_VALUES) == set(_schema_parameters()) + + +@pytest.mark.parametrize("parameter", sorted(_CONFLICTING_VALUES)) +def test_the_helpers_refuse_a_conflict_in_every_declared_parameter(parameter: str) -> None: + """Two references that disagree about any declared parameter differ. + + The comparison walks the parameters the model declares, so a parameter + added to the schema is compared without being named here. + """ + supported_value, requested_value = _CONFLICTING_VALUES[parameter] + supported = {**_BASE, parameter: supported_value} + requested = {**_BASE, parameter: requested_value} + + assert not formats_are_equivalent(requested, supported) + assert not format_is_supported(requested, supported) + + +def test_format_is_supported_refuses_a_request_that_omits_a_required_pixel_ratio() -> None: + """A 2x product is not served by a creative that never claims a density. + + ``core/format-id.json`` makes the omitted form the 1x variant ("Omit for + the backward-compatible 1x variant"), so silence here is a 1x asset being + delivered into a 2x slot, not an unspecified parameter. + """ + supported = {**_BASE, "pixel_ratio": 2} + + assert formats_are_equivalent(_BASE, supported) + assert not format_is_supported(_BASE, supported) + + +def test_format_is_supported_accepts_the_omitted_form_of_a_1x_product() -> None: + """The mirror of the case above, and the reason it is not a plain equality. + + A seller that spells ``pixel_ratio: 1`` publishes the same render as one + that omits it, so a request omitting the field must still be served. + """ + assert format_is_supported(_BASE, {**_BASE, "pixel_ratio": 1}) + + +def test_upgrade_legacy_format_id_keeps_every_parameter_it_was_given() -> None: + """The legacy-size upgrade reparameterizes the ID; it drops no parameter.""" + legacy = FormatId( + agent_url=CANONICAL_CREATIVE_AGENT_URL, + id="display_300x250", + width=300, + height=250, + duration_ms=15000, + pixel_ratio=2, + ) + + upgraded = upgrade_legacy_format_id(legacy) + + assert upgraded.id == "display_image" + assert (upgraded.width, upgraded.height) == (300, 250) + assert upgraded.duration_ms == 15000 + assert upgraded.pixel_ratio == 2 diff --git a/tests/test_canonical_formats_declaration_v1_refs.py b/tests/test_canonical_formats_declaration_v1_refs.py new file mode 100644 index 000000000..126b36c26 --- /dev/null +++ b/tests/test_canonical_formats_declaration_v1_refs.py @@ -0,0 +1,100 @@ +"""Every read of a declaration's v1 refs accepts both declaration types. + +Two types in this package are called a product format declaration and they name +this field differently: + +* the sixteen generated ``ProductFormatDeclaration`` arms declare + ``v1_format_ref`` as a real field — this is what + ``adcp.types.domains.core.product.Product.format_options`` holds; +* ``adcp.types.canonical_creative.Format`` exposes ``legacy_format_refs``, a + property over private state populated from ``v1_format_ref`` at construction. + +Every function that reads the refs was written against the second spelling, so +each one raises ``AttributeError`` when handed the first. These tests grade the +entry points against BOTH types, which is the only way the asymmetry shows up. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from adcp.canonical_formats import find_declaration_by_v1_format_id +from adcp.canonical_formats.projection import resolve_legacy_format_refs +from adcp.canonical_formats.v1_to_v2 import group_declarations_by_product +from adcp.canonical_formats.v2_to_v1 import project_declaration_to_v1 +from adcp.types.canonical_creative import ProductFormatDeclaration as HandWrittenDeclaration +from adcp.types.domains.core.product_format_declaration import ( + ProductFormatDeclaration1 as GeneratedDeclaration, +) +from adcp.types.legacy import LegacyFormatId + +AGENT_URL = "https://creative.adcontextprotocol.org" +V1_ID = "display_300x250_image" +DECLARATION_DOCUMENT: dict[str, Any] = { + "format_kind": "image", + "format_option_id": "opt1", + "params": {"width": 300, "height": 250}, + "v1_format_ref": [{"agent_url": AGENT_URL, "id": V1_ID}], +} + + +def _generated() -> Any: + return GeneratedDeclaration.model_validate(DECLARATION_DOCUMENT) + + +def _hand_written() -> Any: + return HandWrittenDeclaration.model_validate(DECLARATION_DOCUMENT) + + +#: Both spellings of the same declaration. The ids name the field each one +#: declares, so a failure says which spelling the reader could not handle. +DECLARATION_BUILDERS = [ + pytest.param(_generated, id="generated-v1_format_ref"), + pytest.param(_hand_written, id="handwritten-legacy_format_refs"), +] + + +def test_the_two_declaration_types_spell_the_field_differently() -> None: + """The premise of this module, asserted rather than assumed. + + If a later change unifies the two types, this test fails and the rest of + the module becomes redundant rather than silently grading one shape twice. + """ + generated, hand_written = _generated(), _hand_written() + + assert "v1_format_ref" in type(generated).model_fields + assert not hasattr(generated, "legacy_format_refs") + + assert "v1_format_ref" not in type(hand_written).model_fields + assert hasattr(hand_written, "legacy_format_refs") + + +@pytest.mark.parametrize("build", DECLARATION_BUILDERS) +def test_resolve_legacy_format_refs_reads_either_spelling(build: Any) -> None: + assert [ref.id for ref in resolve_legacy_format_refs(build())] == [V1_ID] + + +@pytest.mark.parametrize("build", DECLARATION_BUILDERS) +def test_find_declaration_by_v1_format_id_reads_either_spelling(build: Any) -> None: + declaration = build() + target = LegacyFormatId.model_validate({"agent_url": AGENT_URL, "id": V1_ID}) + + assert find_declaration_by_v1_format_id(target, [declaration]) is declaration + + +@pytest.mark.parametrize("build", DECLARATION_BUILDERS) +def test_project_declaration_to_v1_reads_either_spelling(build: Any) -> None: + projected = project_declaration_to_v1(build()) + + assert [ref.id for ref in projected.format_ids] == [V1_ID] + + +@pytest.mark.parametrize("build", DECLARATION_BUILDERS) +def test_group_declarations_by_product_reads_either_spelling(build: Any) -> None: + declaration = build() + + grouped = group_declarations_by_product([declaration], {"prod_1": [V1_ID]}) + + assert grouped == {"prod_1": [declaration]} diff --git a/tests/test_canonical_models_are_subclasses.py b/tests/test_canonical_models_are_subclasses.py index d9d3c9cc3..f5968e6b6 100644 --- a/tests/test_canonical_models_are_subclasses.py +++ b/tests/test_canonical_models_are_subclasses.py @@ -56,7 +56,7 @@ def _ids(models: tuple[type[BaseModel], ...]) -> list[str]: def test_the_canonical_model_list_is_not_empty() -> None: """A later refactor must not satisfy these tests by emptying the list.""" - assert len(PRIMARY_CANONICAL_MODELS) == 31 + assert len(PRIMARY_CANONICAL_MODELS) == 33 @pytest.mark.parametrize("model", PRIMARY_CANONICAL_MODELS, ids=_ids(PRIMARY_CANONICAL_MODELS)) @@ -170,3 +170,194 @@ def test_an_inherited_validator_that_reads_a_removed_field_still_works() -> None canonical_creative.Creative.model_validate( {k: v for k, v in row.items() if k != "format_kind"} ) + + +def _canonical_branch_pairs() -> list[tuple[type[BaseModel], type[BaseModel]]]: + """Every canonical class paired with the generated BRANCH it refines. + + Derived from the MRO rather than listed: a canonical class refines a + generated branch when a generated class sits in its bases and that class is + itself a subclass of a generated split root's base. A schema refresh that + splits a fourteenth root is covered without editing this. + + The branch set comes from ``_branch_models`` -- the method the dispatch hook + itself consults -- not from the ``<Root>Branches`` alias. Reading the alias + covered only roots that export one, which excluded the five public response + bases: their branches are the numbered classes the generator writes, and + those are exported individually rather than behind a union alias. Nothing + canonical refines one today, so the gap was in the derivation rather than in + the coverage, and a derivation with a hole in it is the kind that goes stale + without a failure. + """ + import adcp.types._generated as generated + + branches: set[type[BaseModel]] = set() + for name in dir(generated): + candidate = getattr(generated, name) + if ( + isinstance(candidate, type) + and issubclass(candidate, BaseModel) + and "_branch_models" in candidate.__dict__ + ): + branches.update(candidate._branch_models()) + + pairs: list[tuple[type[BaseModel], type[BaseModel]]] = [] + for name in dir(canonical_creative): + candidate = getattr(canonical_creative, name) + if not isinstance(candidate, type) or not issubclass(candidate, BaseModel): + continue + refined = [base for base in candidate.__mro__[1:] if base in branches] + if refined: + pairs.append((candidate, refined[0])) + return pairs + + +def test_the_canonical_branch_population_is_not_empty() -> None: + """A derivation that selects nothing passes for free.""" + pairs = _canonical_branch_pairs() + assert len(pairs) >= 3, f"only {len(pairs)} canonical classes refine a generated branch" + + +@pytest.mark.parametrize( + "canonical,generated", + _canonical_branch_pairs(), + ids=lambda model: getattr(model, "__name__", str(model)), +) +def test_a_canonical_branch_keeps_its_generated_branchs_requiredness( + canonical: type[BaseModel], generated: type[BaseModel] +) -> None: + """Requiredness is the branch's own, field by field. + + A canonical class refines the TYPE of a field; it must not change whether + the field is required. Requiredness is what distinguishes one branch from + another, so relaxing it here reinstates the flattening that branch classes + exist to remove -- a document satisfying no branch would be accepted at the + canonical surface, which is the one a consumer touches. + + The mechanism that keeps this true is base ORDER, and it is a two-token + edit away from being wrong, which is why this is asserted rather than + documented. + """ + divergent = [ + name + for name, field in generated.model_fields.items() + if name in canonical.model_fields + and canonical.model_fields[name].is_required() != field.is_required() + ] + assert not divergent, ( + f"{canonical.__name__} diverges from {generated.__name__} on the requiredness of " + f"{divergent}. The usual cause is base ORDER: the generated branch must be listed " + f"FIRST and CanonicalBoundaryModel LAST, so the branch's requiredness wins by MRO " + f"position and model_config still resolves from the canonical side. With the " + f"boundary first, every field the branch does not re-declare reverts to optional." + ) + + +@pytest.mark.parametrize( + "canonical,generated", + _canonical_branch_pairs(), + ids=lambda model: getattr(model, "__name__", str(model)), +) +def test_a_canonical_branch_declares_every_field_its_generated_branch_declares( + canonical: type[BaseModel], generated: type[BaseModel] +) -> None: + """No field is lost on the way to the canonical surface, except by the rule. + + ``CanonicalBoundaryModel`` removes legacy creative identity from a canonical + model's DECLARED fields, which is the layer's purpose. That exception is + read from the same predicate the production strip uses, so the test cannot + drift from the rule it is excepting -- a hand-listed field name would admit + a second removal nobody decided on. + """ + from adcp.types.canonical_creative import is_legacy_creative_identity_key + + missing = sorted( + name + for name in set(generated.model_fields) - set(canonical.model_fields) + if not is_legacy_creative_identity_key(name) + ) + assert not missing, f"{canonical.__name__} drops {missing} declared by {generated.__name__}" + + +def _where_base_order_is_expressible( + canonical: type[BaseModel], generated: type[BaseModel], boundary: type[BaseModel] +) -> set[str]: + """Fields on which reordering the bases would change requiredness. + + Three conditions, and the third took two corrections to state: the generated + branch REQUIRES the field, the canonical class does not re-declare it in its + own body, and the boundary-side base CARRIES that field with a different + requiredness. + + Both looser versions demanded failures that cannot happen. Dropping the + third condition entirely named fields required on both sides, which order + cannot change. Phrasing it as "the boundary does not also require it" named + fields the boundary does not carry at all -- pydantic takes a field only + from a class that declares it, so a base contributing nothing for a name + cannot affect it whatever the order. + + Where this set is empty the defect cannot appear, and a probe aimed there + passes while proving nothing -- which is what happened the first time this + was probed by hand, on the one branch that re-declares every field it + requires. + """ + own = set(getattr(canonical, "__annotations__", {})) + return { + name + for name, field in generated.model_fields.items() + if field.is_required() + and name not in own + and name in boundary.model_fields + and not boundary.model_fields[name].is_required() + } + + +def test_the_requiredness_test_fails_wherever_the_defect_can_appear() -> None: + """A probe over the POPULATION, so it cannot be aimed at a blind spot. + + For each canonical branch where base order is expressible, rebuild the class + with the boundary base FIRST -- carrying its own body across, so order is the + only difference -- and require that the requiredness comparison fails. A + probe that changed the body as well would report flattening from the wrong + cause. + """ + pairs = _canonical_branch_pairs() + assert pairs, "the derivation selected nothing" + + probed = 0 + for canonical, generated in pairs: + # The DECLARED bases, swapped -- not "the first non-generated class in the + # MRO", which picked a sibling canonical model and produced an + # inconsistent MRO rather than a measurement. + others = [base for base in canonical.__bases__ if base is not generated] + if len(others) != 1: + continue + boundary = others[0] + expressible = _where_base_order_is_expressible(canonical, generated, boundary) + if not expressible: + continue + namespace: dict[str, object] = { + "__annotations__": dict(getattr(canonical, "__annotations__", {})) + } + for name in namespace["__annotations__"]: # type: ignore[attr-defined] + field = canonical.model_fields.get(name) + if field is not None and not field.is_required(): + namespace[name] = field.default + reordered = type(f"Reordered{canonical.__name__}", (boundary, generated), namespace) + diverged = [ + name + for name, field in generated.model_fields.items() + if name in reordered.model_fields + and reordered.model_fields[name].is_required() != field.is_required() + ] + assert diverged, ( + f"reordering {canonical.__name__}'s bases changed nothing, yet the defect is " + f"expressible on {sorted(expressible)}. Either the probe no longer changes " + f"what it thinks it changes, or requiredness no longer comes from MRO position." + ) + probed += 1 + + assert probed, ( + "no canonical branch can express the base-order defect, so " + "test_a_canonical_branch_keeps_its_generated_branchs_requiredness grades nothing" + ) diff --git a/tests/test_canonical_removed_fields.py b/tests/test_canonical_removed_fields.py index d0ad661b8..b390f1602 100644 --- a/tests/test_canonical_removed_fields.py +++ b/tests/test_canonical_removed_fields.py @@ -22,7 +22,8 @@ field disagrees with a runtime that refuses it: * the ``TYPE_CHECKING`` redeclaration exists, says ``init=False``, and restates - the generated parent's annotation EXACTLY; + the generated parent's field type — compared as pydantic RESOLVES both sides, + since the written spelling and the ``FieldInfo.annotation`` differ for ``None``; * mypy and pyright both refuse the constructor keyword. Nothing here is a hand-written table. The model list is @@ -47,7 +48,7 @@ from typing import Any import pytest -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, ValidationError, create_model from adcp.types import canonical_creative from adcp.types.canonical_creative import ( @@ -59,9 +60,18 @@ _GENERATED_PREFIX = "adcp.types.domains." _SOURCE = Path(canonical_creative.__file__) -#: The boundary validator's refusal text. Asserting on it keeps the -#: ``model_validate`` test from passing because of a missing required field. -_REFUSAL = "contains legacy creative identity" +#: The two owned refusals. Asserting on the text keeps the ``model_validate`` +#: test from passing because of a missing required field — and asserting on the +#: text of the mechanism that OWNS the name on that class keeps it from passing +#: because the other mechanism happened to catch it. +#: +#: ``_REFUSAL_BOUNDARY`` is ``CanonicalBoundaryModel._reject_legacy_creative_identity``. +#: ``_REFUSAL_BRANCH`` is ``adcp.types._conditions``' forbidden-names check, which +#: runs first on a class bound to a branch that forbids the name — ``CreativeAsset`` +#: is bound to the canonical-format branch of ``core/creative-asset.json`` and that +#: branch bans ``format_id`` outright. +_REFUSAL_BOUNDARY = "contains legacy creative identity" +_REFUSAL_BRANCH = "is not accepted by this request shape" def _generated_ancestors(model: type[BaseModel]) -> list[type[BaseModel]]: @@ -174,20 +184,43 @@ def test_the_removed_field_is_absent_from_model_dump(model: type[BaseModel], nam assert name not in model.model_construct().model_dump() +def _expected_refusal(model: type[BaseModel], name: str) -> str: + """Which mechanism owns *name* on *model* — DERIVED, not listed. + + A branch that forbids a name types it ``None``, so the generated ancestor's + resolved field type is the whole rule. Measured over all fifteen pairs: + exactly one, ``CreativeAsset.format_id``, resolves to ``NoneType`` and is + refused by the branch; the other fourteen inherit a real type and reach the + boundary validator. + """ + + inherited = _declaring_ancestor(model, name).model_fields[name].annotation + if _as_pydantic_resolves_it(inherited) is type(None): + return _REFUSAL_BRANCH + return _REFUSAL_BOUNDARY + + @pytest.mark.parametrize(("model", "name"), REMOVED, ids=REMOVED_IDS) def test_model_validate_rejects_a_document_carrying_the_removed_field( model: type[BaseModel], name: str ) -> None: - """Refused, and refused for the boundary's reason. + """Refused, and refused by the mechanism that owns the name on this class. A document holding only the removed name would also fail for missing required fields on most of these models, so the refusal text is asserted: - without it this test would stay green if the boundary validator were + without it this test would stay green if the refusing validator were deleted outright. + + Naming the EXPECTED mechanism rather than accepting either text is what + keeps both deletions red. Delete the branch's forbidden-names check and + ``CreativeAsset`` falls through to the boundary's text, which is not the one + expected; delete the boundary validator and the fourteen root-bound models + fall through to ``missing``. """ + expected = _expected_refusal(model, name) with pytest.raises(ValidationError) as caught: model.model_validate({name: "legacy-value"}) - assert _REFUSAL in str(caught.value), str(caught.value) + assert expected in str(caught.value), str(caught.value) # --------------------------------------------------------------------------- @@ -244,6 +277,30 @@ def _alias_namespace() -> dict[str, Any]: return namespace +def _as_pydantic_resolves_it(annotation: Any) -> Any: + """*annotation* as pydantic records it on a ``FieldInfo``. + + The declared side is read out of the source and evaluated, so a field + annotated ``None`` arrives as the VALUE ``None`` while the ancestor's + ``FieldInfo.annotation`` holds the TYPE ``NoneType`` — pydantic normalises + one to the other while building the field. Comparing the two spellings + directly reports ``CreativeAsset.format_id`` as disagreeing with its parent + at the moment it restates the parent exactly, and NO spelling in the source + can settle it: ``NoneType`` is the spelling the comparison wants and mypy + refuses that outright (*NoneType should not be used as a type, please use + None instead*). So both sides go through pydantic's own normalisation and + the comparison is between RESOLVED types rather than between annotation + text. + + Measured idempotent on every other annotation this module compares — + ``FormatReferenceStructuredObject``, its optional, ``list[...]``, + ``Sequence[...]`` and that optional all come back unchanged — so the + normalisation collapses the one representation difference and nothing else. + """ + + return create_model("_Resolved", field=(annotation, None)).model_fields["field"].annotation + + @pytest.mark.parametrize(("model", "name"), REMOVED, ids=REMOVED_IDS) def test_the_removed_field_is_hidden_from_the_type_checker( model: type[BaseModel], name: str @@ -252,9 +309,12 @@ def test_the_removed_field_is_hidden_from_the_type_checker( ``init=False`` is what removes the keyword from the ``__init__`` mypy and pyright synthesize through ``dataclass_transform``. The annotation must be - the generated parent's exact field type: a narrowed one is an incompatible + the generated parent's field type: a narrowed one is an incompatible override (pyright says so by name), and a widened one hands the caller a - read type the runtime never produces. + read type the runtime never produces. ``_as_pydantic_resolves_it`` is what + makes "the same type" mean the resolved type rather than the written one — + a branch-bound class annotates ``None`` where its parent's ``FieldInfo`` + reads ``NoneType``, and mypy refuses the second spelling. """ declaration = _type_checking_redeclarations().get((model.__name__, name)) assert declaration is not None, ( @@ -270,7 +330,12 @@ def test_the_removed_field_is_hidden_from_the_type_checker( assert keywords.get("init") is False, ast.unparse(declaration) declared = eval(ast.unparse(declaration.annotation), _alias_namespace()) - assert declared == _declaring_ancestor(model, name).model_fields[name].annotation + inherited = _declaring_ancestor(model, name).model_fields[name].annotation + assert _as_pydantic_resolves_it(declared) == _as_pydantic_resolves_it(inherited), ( + f"{model.__name__}.{name} is annotated {declared!r}, which resolves to " + f"{_as_pydantic_resolves_it(declared)!r}, where its generated parent's field " + f"resolves to {_as_pydantic_resolves_it(inherited)!r}" + ) def _construction(model: type[BaseModel], names: list[str]) -> str: diff --git a/tests/test_client.py b/tests/test_client.py index b4959b3b2..9cb0d8ef6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -221,7 +221,7 @@ async def test_get_products(): mock_parsed_result = TaskResult[GetProductsResponse]( status=TaskStatus.COMPLETED, - data=GetProductsResponse(products=[]), # Properly typed result + data=GetProductsResponse(products=[], cache_scope="public"), # Properly typed result success=True, ) @@ -261,6 +261,9 @@ def test_get_products_preserves_canonical_format_options(): status=TaskStatus.COMPLETED, success=True, data={ + # Required on every 3.1+ get_products response by the root + # conditional's standard branch, alongside ``products``. + "cache_scope": "public", "products": [ _get_products_product( format_options=[ @@ -271,7 +274,7 @@ def test_get_products_preserves_canonical_format_options(): } ] ) - ] + ], }, ) @@ -303,6 +306,7 @@ def test_get_products_still_projects_legacy_format_ids(): status=TaskStatus.COMPLETED, success=True, data={ + "cache_scope": "public", "products": [ _get_products_product( format_ids=[ @@ -312,7 +316,7 @@ def test_get_products_still_projects_legacy_format_ids(): } ] ) - ] + ], }, ) @@ -774,6 +778,10 @@ async def test_all_client_methods(): "plan_id": "plan_123", "outcome": "completed", "governance_context": "ctx-abc-123-governance", + # A completed outcome names the check it completes and carries the + # seller's response (governance/report-plan-outcome-request.json). + "check_id": "chk_123", + "seller_response": {"seller_reference": "mb_123"}, }, ), ( @@ -956,7 +964,7 @@ async def test_multi_agent_parallel_execution(): mock_result = TaskResult( status=TaskStatus.COMPLETED, - data={"products": []}, + data={"products": [], "cache_scope": "public"}, success=True, ) diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index 2edd41988..b30b7c0db 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -12,6 +12,7 @@ import ast import copy import json +import re from pathlib import Path import pytest @@ -107,7 +108,13 @@ def test_restore_principal_result_aliases_uses_kind_discriminators(tmp_path, mon def test_disambiguate_comply_response_arm_renames_class_and_references(tmp_path, monkeypatch): - """Fresh codegen output cannot add a generic public Arm collision.""" + """Fresh codegen output cannot add a generic public ``Arm`` collision. + + ``Arm`` keeps its name here while the rest of the constraint vocabulary + says "branch": the compliance response declares an ``arm`` PROPERTY, so + this enum is named after a wire field rather than after a combinator's + branch, and ``ComplyResponseArm`` is a generated public class. + """ from scripts import post_generate_fixes compliance_dir = tmp_path / "compliance" @@ -223,21 +230,36 @@ def test_post_generation_restores_codegen_contract_compatibility(tmp_path, monke for relative_path, response_name in response_specs: response_source = (tmp_path / relative_path).read_text() - dispatch_import = "from adcp.types.response_dispatch import ResponseArmDispatchMixin" - assert dispatch_import in response_source + # The same hook the split roots mix in, not a second implementation. + # The response side used to carry its own ``model_validate`` override; + # see ``tests/test_branch_dispatch_on_every_path.py`` for what that + # override did and did not enforce. + assert "from adcp.types._branch_dispatch import BranchDispatchMixin" in response_source assert ( - f"class {response_name}(ResponseArmDispatchMixin, " + f"class {response_name}(BranchDispatchMixin, " "AdcpVersionEnvelope, ProtocolEnvelope):" in response_source ) assert f"class {response_name}1({response_name}):" in response_source - arm_method = ( - "def _response_arm_models(cls) -> tuple[type[" + response_name + "], ...]:\n" + branch_method = ( + "def _branch_models(cls) -> tuple[type, ...]:\n" + " # The branches subclass this class, so they are not defined while\n" + " # this body is compiled. The hook resolves them on first use, after\n" + " # the model_rebuild below.\n" " return (\n" " " + response_name + "1,\n" " " + response_name + "2,\n" " )" ) - assert arm_method in response_source + assert branch_method in response_source + # The combinator is read off the root's schema, and every one of these + # five roots declares ``oneOf``. + assert " def _branch_combinator(cls) -> str:" in response_source + assert " return 'oneOf'" in response_source + # The branches are declared below the base, so the rebuild is what + # installs their schema. + assert response_source.rstrip().endswith( + f"{response_name}.model_rebuild(force=True)" + ), response_source[-200:] assert f"{response_name} =" not in response_source @@ -514,7 +536,7 @@ def test_nested_format_discriminator_drops_only_codegen_ambiguous_outer_hint(): assert items["oneOf"][0]["properties"]["item_type"]["const"] == "individual" -def test_audience_evidence_attestation_subject_uses_narrowed_resource_arm(): +def test_audience_evidence_attestation_subject_uses_narrowed_resource_branch(): """The allOf-constrained evidence subject must build a valid Pydantic schema.""" from adcp.types.domains.core.audience_evidence import AttestationRef @@ -527,7 +549,7 @@ def test_audience_evidence_attestation_subject_uses_narrowed_resource_arm(): def test_post_generate_legacy_purchase_losses_are_always_an_array(tmp_path, monkeypatch): - """The schema's negated empty array must not become an empty object arm.""" + """The schema's negated empty array must not become an empty object branch.""" from scripts import post_generate_fixes generated_dir = tmp_path / "domains" @@ -786,7 +808,7 @@ def test_list_creatives_merged_model_restores_xor_and_legacy_aliases(tmp_path, m fixed = target.read_text() assert "def _validate_format_reference_xor(self) -> Creative:" in fixed assert "Creatives = Creative\nCreatives1 = Creative" in fixed - # The record's schema states the rule as a ``oneOf`` whose arms forbid each + # The record's schema states the rule as a ``oneOf`` whose branches forbid each # other, so that is the keyword the refusal names. A bare ``ValueError`` # reaches the consumer as ``value_error`` and names no keyword at all. assert "from pydantic_core import PydanticCustomError" in fixed @@ -899,7 +921,7 @@ def test_post_generate_restores_combined_get_products_field_enum(tmp_path, monke @pytest.mark.parametrize("catalog_field", ["properties", "placements", "collections", "signals"]) -def test_generated_adagents_rejects_null_required_catalog_arm(catalog_field): +def test_generated_adagents_rejects_null_required_catalog_branch(catalog_field): from pydantic import TypeAdapter, ValidationError from adcp.types.domains.adagents import AdcpAgentsAuthorization @@ -935,7 +957,7 @@ def test_post_generate_injects_postal_pairing_validator_idempotently(tmp_path, m assert "'US': ('zip', 'zip_plus_four')" in fixed -def test_post_generate_prefers_legacy_postal_union_arm_idempotently(tmp_path, monkeypatch): +def test_post_generate_prefers_legacy_postal_union_branch_idempotently(tmp_path, monkeypatch): from scripts import post_generate_fixes generated_dir = tmp_path / "domains" @@ -949,8 +971,8 @@ def test_post_generate_prefers_legacy_postal_union_arm_idempotently(tmp_path, mo ) monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) - post_generate_fixes.fix_postal_union_arm_order() - post_generate_fixes.fix_postal_union_arm_order() + post_generate_fixes.fix_postal_union_branch_order() + post_generate_fixes.fix_postal_union_branch_order() fixed = target.read_text() assert "PostalArea1 | PostalArea2" not in fixed @@ -1022,7 +1044,7 @@ def test_unwrap_rootmodel_unions_keeps_value_roots_wrapped(tmp_path, monkeypatch assert target.read_text() == source -def test_unwrap_rootmodel_unions_keeps_arms_of_a_discriminated_union(tmp_path, monkeypatch): +def test_unwrap_rootmodel_unions_keeps_branches_of_a_discriminated_union(tmp_path, monkeypatch): """A wrapper the parent's discriminator maps a tag to keeps its wrapper. Both ``VastAsset`` shapes carry ``asset_type='vast'``, so inlining the union @@ -1058,11 +1080,13 @@ def test_unwrap_rootmodel_unions_keeps_arms_of_a_discriminated_union(tmp_path, m assert "AssetVariant = Annotated[" in rewritten -def test_unwrap_rootmodel_unions_keeps_an_arm_of_another_modules_field_union(tmp_path, monkeypatch): - """A composing root another module's smart union names as an arm keeps its wrapper. +def test_unwrap_rootmodel_unions_keeps_an_branch_of_another_modules_field_union( + tmp_path, monkeypatch +): + """A composing root another module's smart union names as an branch keeps its wrapper. ``creative_localization.assets`` is ``LocalizedCreativeAsset | Assets``; inlining - the root would let Pydantic's smart mode score its arms against ``Assets`` + the root would let Pydantic's smart mode score its branches against ``Assets`` directly and pick a different one for the same payload. A discriminated root is exempt (a nested tagged union is selected by its tag), and so is a root a union *root* composes — that flattening predates this pass. @@ -1143,7 +1167,7 @@ def test_unwrap_rootmodel_unions_subclasses_a_single_model_root(tmp_path, monkey def test_unwrap_rootmodel_unions_keeps_a_root_with_a_validator(tmp_path, monkeypatch): - """The wrapper is the only place a cross-arm validator can live.""" + """The wrapper is the only place a cross-branch validator can live.""" from scripts import post_generate_fixes generated_dir = tmp_path / "domains" @@ -1167,11 +1191,11 @@ def test_unwrap_rootmodel_unions_keeps_a_root_with_a_validator(tmp_path, monkeyp def test_discriminated_union_roots_report_one_tag_error(): - """A union the schema discriminates reports the tag, not every arm. + """A union the schema discriminates reports the tag, not every branch. ``core/deployment.json`` and ``media-buy/list-products-response.json`` both declare ``discriminator``. An unwrap that drops it turns one - ``union_tag_invalid`` into one error per arm per mismatched field. + ``union_tag_invalid`` into one error per branch per mismatched field. """ import pytest from pydantic import TypeAdapter, ValidationError @@ -1189,8 +1213,8 @@ def test_discriminated_union_roots_report_one_tag_error(): assert [error["type"] for error in errors] == ["union_tag_invalid"], errors -def test_account_reference_arms_are_reachable_through_the_alias(): - """The union alias exposes its arms wherever a field references it.""" +def test_account_reference_branches_are_reachable_through_the_alias(): + """The union alias exposes its branches wherever a field references it.""" import types as builtin_types from adcp.types import AccountReference, AccountReferenceById @@ -1208,8 +1232,8 @@ def test_product_change_map_uses_valid_constrained_string_key_type(): assert schema["propertyNames"]["minLength"] == 1 -def test_protocol_envelope_import_restored_for_response_arms(): - """Response arms that inherit ProtocolEnvelope must keep the import.""" +def test_protocol_envelope_import_restored_for_response_branches(): + """Response branches that inherit ProtocolEnvelope must keep the import.""" from scripts.post_generate_fixes import _sync_protocol_envelope_import source = ( @@ -1284,7 +1308,7 @@ def test_consolidated_export_timestamp_is_stable_when_content_is_unchanged(): ) -def test_semantic_response_aliases_resolve_to_concrete_generated_arms(): +def test_semantic_response_aliases_resolve_to_concrete_generated_branches(): """Semantic aliases must not fall back to the whole response union.""" from importlib import import_module @@ -1336,8 +1360,8 @@ def test_semantic_response_aliases_resolve_to_concrete_generated_arms(): assert alias is expected, f"{alias_name} no longer points at {module_name}.{class_name}" -def test_sync_creatives_response_arm_matches_schema_creative_fields(): - """sync_creatives response arm must expose every schema Creative field.""" +def test_sync_creatives_response_branch_matches_schema_creative_fields(): + """sync_creatives response branch must expose every schema Creative field.""" import json from pathlib import Path @@ -1372,7 +1396,7 @@ def test_sync_creatives_response_arm_matches_schema_creative_fields(): Creative.model_validate(payload) -def test_sync_creatives_response_arm_accepts_submitted_response(): +def test_sync_creatives_response_branch_accepts_submitted_response(): """sync_creatives schema includes a submitted async response branch.""" from pydantic import TypeAdapter @@ -1394,8 +1418,8 @@ def test_sync_creatives_response_arm_accepts_submitted_response(): assert response.task_id == "task_123" -def test_schema_derived_response_arms_preserve_nested_validation(): - """Schema-derived response arms should not widen structured fields to Any.""" +def test_schema_derived_response_branches_preserve_nested_validation(): + """Schema-derived response branches should not widen structured fields to Any.""" from datetime import date import pytest @@ -1477,10 +1501,10 @@ def test_schema_derived_response_arms_preserve_nested_validation(): # both of which read the public model rather than a string in a generated file. -def test_post_generate_sync_creatives_response_arms_match_schema_creative_fields( +def test_post_generate_sync_creatives_response_branches_match_schema_creative_fields( tmp_path, monkeypatch ): - """The post-generation response arms must stay aligned with the schema.""" + """The post-generation response branches must stay aligned with the schema.""" import ast import json from pathlib import Path @@ -1802,7 +1826,17 @@ def test_targeting_public_surface_guard_accepts_a_compatible_new_subclass(monkey def test_current_targeting_input_has_exactly_four_generated_request_sites(): - """Pin schema sites and pre-patch annotations, independent of runtime widening.""" + """Pin the schema sites, and that every one of them carries the bridge. + + The generated side used to be pinned to the PRE-PATCH annotation + (``targeting_input.TargetingOverlayInput | None``), because the bridge was applied + at import time and the generated source never showed it. The bridge is now written + at the declaration site, so the generated source is where it is visible -- and the + stronger property is pinnable here: the four schema sites are the four alias sites, + and NO generated field carries the raw input type. A fifth schema site that the + declarative source does not cover would leave a raw field behind and fail the last + assertion, which the old spelling could not see. + """ import ast import json @@ -1846,10 +1880,11 @@ def references(node, path=()): } generated_root = root / "src/adcp/types/domains" - generated_sites = set() + bridged_sites = set() + raw_sites = set() for source in generated_root.rglob("*.py"): text = source.read_text() - if "TargetingOverlayInput" not in text: + if "TargetingOverlay" not in text: continue for model in ast.walk(ast.parse(text)): if not isinstance(model, ast.ClassDef): @@ -1857,22 +1892,30 @@ def references(node, path=()): for field in model.body: if not isinstance(field, ast.AnnAssign): continue - if "TargetingOverlayInput" not in ast.unparse(field.annotation): - continue - generated_sites.add( - (source.relative_to(generated_root).as_posix(), model.name, field.target.id) + annotation = ast.unparse(field.annotation) + site = ( + source.relative_to(generated_root).as_posix(), + model.name, + field.target.id, ) - # Assert the whole schema shape: a nested/list/wider union - # must not pass merely because it still contains Input. + if "TargetingOverlayInput" in annotation: + raw_sites.add(site) + if "TargetingOverlayMutation" not in annotation: + continue + bridged_sites.add(site) + # Assert the whole shape: a nested or wider annotation must not pass + # merely because it mentions the alias somewhere. assert isinstance(field.annotation, ast.Subscript) assert ast.unparse(field.annotation.value) == "Annotated" - assert ast.unparse(field.annotation.slice.elts[0]) == ( - "targeting_input.TargetingOverlayInput | None" - ) - assert generated_sites == { + assert ast.unparse(field.annotation.slice.elts[0]) == "TargetingOverlayMutation" + assert bridged_sites == { (f"media_buy/{stem.replace('-', '_')}.py", name, "targeting_overlay") for stem, name in expected.items() } + # The bridge is not additive: a generated field still typed with the raw input type + # is a mutation site the declarative source in scripts/post_generate_fixes.py does + # not cover, and it would be un-bridged for every caller who reaches it by nesting. + assert raw_sites == set() def test_product_type_structure(): @@ -2146,11 +2189,11 @@ def test_post_generate_required_nullable_field_stays_required_and_nullable(tmp_p schema_path = ( Path("schemas") / "cache" / bundle_key / "media-buy" / "create-media-buy-response.json" ) - success_arm = json.loads(schema_path.read_text())["oneOf"][0] + success_branch = json.loads(schema_path.read_text())["oneOf"][0] # Guard the premise: the fix is only meaningful while the schema keeps # declaring confirmed_at as required-and-nullable. - assert "confirmed_at" in success_arm["required"] - assert "null" in success_arm["properties"]["confirmed_at"]["type"] + assert "confirmed_at" in success_branch["required"] + assert "null" in success_branch["properties"]["confirmed_at"]["type"] module = ast.parse(generated_source) success_class = next( @@ -2204,14 +2247,14 @@ def test_generated_create_media_buy_success_matches_schema_nullability(): assert confirmed_at.value is None -def _write_response_arm_fixture( +def _write_response_branch_fixture( tmp_path, monkeypatch, *, root_all_of: list[dict], - arms: list[dict], + branches: list[dict], ): - """Drive the response-arm emitter over a synthetic account response schema. + """Drive the response-branch emitter over a synthetic account response schema. Only ``account/sync-accounts-response.json`` is materialized, so every other entry in ``response_specs`` is skipped for missing schema/target. @@ -2241,7 +2284,7 @@ def _write_response_arm_fixture( "title": "Sync Accounts Response", "type": "object", "allOf": root_all_of, - "oneOf": arms, + "oneOf": branches, } ) ) @@ -2258,7 +2301,7 @@ def _write_response_arm_fixture( return source -def _arm_bases(source: str, class_name: str) -> list[str]: +def _branch_bases(source: str, class_name: str) -> list[str]: import ast node = next( @@ -2298,62 +2341,62 @@ def _arm_bases(source: str, class_name: str) -> list[str]: ], ids=["relative", "root-relative", "canonical-url"], ) -def test_root_all_of_protocol_envelope_reaches_every_one_of_arm( +def test_root_all_of_protocol_envelope_reaches_every_one_of_branch( tmp_path, monkeypatch, protocol_envelope_ref ): - """A root ``allOf`` applies to the whole document, so every arm inherits it. + """A root ``allOf`` applies to the whole document, so every branch inherits it. - Regression test for #1136: only the ``status: submitted`` arm used to pick + Regression test for #1136: only the ``status: submitted`` branch used to pick up ``ProtocolEnvelope``, leaving ``status``/``replayed``/``task_id`` untyped - on the success and error arms of the same response. + on the success and error branches of the same response. """ - source = _write_response_arm_fixture( + source = _write_response_branch_fixture( tmp_path, monkeypatch, root_all_of=[ {"$ref": "../core/version-envelope.json"}, {"$ref": protocol_envelope_ref}, ], - arms=[_SUCCESS_ARM, _ERROR_ARM], + branches=[_SUCCESS_ARM, _ERROR_ARM], ) assert "from ..core.protocol_envelope import ProtocolEnvelope" in source for class_name in ("SyncAccountsResponse1", "SyncAccountsResponse2"): - assert _arm_bases(source, class_name) == ["AdcpVersionEnvelope", "ProtocolEnvelope"] + assert _branch_bases(source, class_name) == ["AdcpVersionEnvelope", "ProtocolEnvelope"] def test_nested_root_all_of_protocol_envelope_is_detected(tmp_path, monkeypatch): """The root ``allOf`` walk descends into nested ``allOf`` groupings.""" - source = _write_response_arm_fixture( + source = _write_response_branch_fixture( tmp_path, monkeypatch, root_all_of=[ {"allOf": [{"$ref": "../core/protocol-envelope.json"}]}, ], - arms=[_SUCCESS_ARM, _ERROR_ARM], + branches=[_SUCCESS_ARM, _ERROR_ARM], ) for class_name in ("SyncAccountsResponse1", "SyncAccountsResponse2"): - assert _arm_bases(source, class_name) == ["AdcpVersionEnvelope", "ProtocolEnvelope"] + assert _branch_bases(source, class_name) == ["AdcpVersionEnvelope", "ProtocolEnvelope"] -def test_arms_keep_version_envelope_only_without_root_protocol_envelope(tmp_path, monkeypatch): +def test_branches_keep_version_envelope_only_without_root_protocol_envelope(tmp_path, monkeypatch): """The base is attached from the root schema, not sprayed unconditionally. A response whose root does not compose the protocol envelope keeps the - plain version envelope on its ordinary arms; the ``submitted`` arm still + plain version envelope on its ordinary branches; the ``submitted`` branch still gets ``ProtocolEnvelope`` from its own task-envelope shape. """ - source = _write_response_arm_fixture( + source = _write_response_branch_fixture( tmp_path, monkeypatch, root_all_of=[{"$ref": "../core/version-envelope.json"}], - arms=[_SUCCESS_ARM, _ERROR_ARM, _SUBMITTED_ARM], + branches=[_SUCCESS_ARM, _ERROR_ARM, _SUBMITTED_ARM], ) - assert _arm_bases(source, "SyncAccountsResponse1") == ["AdcpVersionEnvelope"] - assert _arm_bases(source, "SyncAccountsResponse2") == ["AdcpVersionEnvelope"] - assert _arm_bases(source, "SyncAccountsResponse3") == [ + assert _branch_bases(source, "SyncAccountsResponse1") == ["AdcpVersionEnvelope"] + assert _branch_bases(source, "SyncAccountsResponse2") == ["AdcpVersionEnvelope"] + assert _branch_bases(source, "SyncAccountsResponse3") == [ "AdcpVersionEnvelope", "ProtocolEnvelope", ] @@ -2361,14 +2404,14 @@ def test_arms_keep_version_envelope_only_without_root_protocol_envelope(tmp_path def test_unrelated_root_all_of_ref_does_not_attach_protocol_envelope(tmp_path, monkeypatch): """Only ``core/protocol-envelope.json`` counts — not any ``core`` ref.""" - source = _write_response_arm_fixture( + source = _write_response_branch_fixture( tmp_path, monkeypatch, root_all_of=[{"$ref": "../core/push-notification-config.json"}], - arms=[_SUCCESS_ARM], + branches=[_SUCCESS_ARM], ) - assert _arm_bases(source, "SyncAccountsResponse1") == ["AdcpVersionEnvelope"] + assert _branch_bases(source, "SyncAccountsResponse1") == ["AdcpVersionEnvelope"] assert "from ..core.protocol_envelope import ProtocolEnvelope" not in source @@ -2503,7 +2546,7 @@ def test_inline_structural_pointer_ref_rebases_same_document_fragments(tmp_path, assert schema == {"$ref": "/schemas/core/source.json#/$defs/shared"} -def test_inline_structural_pointer_ref_merges_a_one_arm_all_of(tmp_path, monkeypatch): +def test_inline_structural_pointer_ref_merges_a_one_branch_all_of(tmp_path, monkeypatch): """``allOf`` of one pointer merges in, keeping the selection's own keywords.""" from scripts.generate_types import inline_structural_pointer_refs @@ -2572,14 +2615,14 @@ def test_collapse_nullable_unions_rewrites_a_property_as_a_nullable_type(): assert schema["properties"]["name"] == {"type": ["string", "null"], "minLength": 1} -def test_collapse_nullable_unions_leaves_untyped_and_multi_arm_unions_alone(): - """A ``$ref`` arm already yields ``T | None``; a three-arm union is not nullability.""" +def test_collapse_nullable_unions_leaves_untyped_and_multi_branch_unions_alone(): + """A ``$ref`` branch already yields ``T | None``; a three-branch union is not nullability.""" from scripts.generate_types import collapse_nullable_unions - ref_arm = {"anyOf": [{"$ref": "../core/thing.json"}, {"type": "null"}]} - three_arms = {"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]} + ref_branch = {"anyOf": [{"$ref": "../core/thing.json"}, {"type": "null"}]} + three_branches = {"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]} no_null = {"anyOf": [{"type": "string"}, {"type": "integer"}]} - schema = {"properties": {"a": ref_arm, "b": three_arms, "c": no_null}} + schema = {"properties": {"a": ref_branch, "b": three_branches, "c": no_null}} expected = copy.deepcopy(schema) collapse_nullable_unions(schema) @@ -2651,16 +2694,17 @@ def _write_required_group_fixture(tmp_path, monkeypatch, schema, module_source): monkeypatch.setattr(post_generate_fixes, "SCHEMA_DIR", schema_dir) monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) - post_generate_fixes.enforce_root_required_groups() + post_generate_fixes.enforce_root_constraints() return target -def test_root_required_group_validator_enforces_every_declared_group(tmp_path, monkeypatch): - """A root ``anyOf`` over required groups becomes a runtime check. +def test_root_constraint_validator_enforces_each_branch_in_full(tmp_path, monkeypatch): + """A root ``anyOf`` becomes a runtime check over each branch's WHOLE condition. - The groups come from ``required`` alone: an arm's ``not`` and discriminating - ``properties`` narrow that arm further, so "at least one group is present" - is the part of the constraint the generator derives. + Not over each branch's ``required`` names: this fixture's first branch also bans + ``proposal_id``, and a check that read only the names accepted a document + carrying packages AND a proposal -- a document the branch it was credited to + refuses. That reading is what this pass replaced. """ from pydantic import ValidationError @@ -2670,7 +2714,15 @@ def test_root_required_group_validator_enforces_every_declared_group(tmp_path, m fixed = target.read_text() assert "def _require_schema_required_group(self) -> BudgetPlan:" in fixed - assert "(('packages',), ('proposal_id', 'total_budget'),)" in fixed + # Each branch compiles to its own predicate, the branch's ``not`` included, and + # the branches' schema titles are the refusal's own words. + # ``sent`` is presence on the document that is SENT: every property this + # fixture declares forbids null, so a null-valued key is dropped before the + # request leaves and reading it as present would refuse a request no seller + # receives. + assert "not (_c.sent(self, 'proposal_id')) and _c.sent(self, 'packages')" in fixed + assert "_c.sent(self, 'proposal_id') and _c.sent(self, 'total_budget')" in fixed + assert "Explicit packages | Committed proposal" in fixed # The module tail survives: several generated modules close with # compatibility aliases after the root class. assert fixed.rstrip().endswith("LegacyBudgetPlan = BudgetPlan") @@ -2692,6 +2744,10 @@ def test_root_required_group_validator_enforces_every_declared_group(tmp_path, m # A partial group is not a group. with pytest.raises(ValidationError, match="at least one of these field groups"): model.model_validate({"account": "acct_1", "proposal_id": "prop_1"}) + # And a document satisfying one branch's NAMES while violating its ``not`` is + # refused, which is the document the names-only reading accepted. + with pytest.raises(ValidationError, match="at least one of these field groups"): + model.model_validate({"account": "acct_1", "packages": ["pkg_1"], "proposal_id": "prop_1"}) _ONE_OF_REQUIRED_GROUP_SCHEMA = { @@ -2742,7 +2798,9 @@ def test_root_required_group_refusal_names_the_root_combinator( fixed = target.read_text() assert "from pydantic_core import PydanticCustomError" in fixed - assert f"raise PydanticCustomError(\n {keyword!r}," in fixed + # Indentation is not the obligation: the compiled validator nests its + # raise inside the branch test, so the keyword is asserted without it. + assert re.search(rf"raise PydanticCustomError\(\s*{re.escape(repr(keyword))},", fixed), fixed namespace: dict[str, object] = {} exec(compile(fixed, str(target), "exec"), namespace) # noqa: S102 @@ -2760,6 +2818,64 @@ def test_root_required_group_refusal_names_the_root_combinator( assert model.model_validate(accepted).packages == ["pkg_1"] +def _root_combinator(schema: dict) -> tuple[str, object] | None: + """The root combinator a schema declares, or ``None``. + + Asks the constraint reader, which records which keyword the branches came from. + It replaced ``_root_required_groups``, whose return value this once read -- + the question is the same and the answer now comes from the whole of the + root's content rather than from its ``required`` names alone. + """ + from scripts.schema_constraints import root_carries_constraints + + if not root_carries_constraints(schema): + return None + for combinator in ("anyOf", "oneOf"): + branches = schema.get(combinator) + if isinstance(branches, list) and branches: + return combinator, branches + return None + + +def _roots_declaring_a_required_group() -> dict[str, str]: + """``{root class: combinator}`` for every root the reader can grade. + + Derived from the bundle: a root declaring an ``anyOf``/``oneOf`` whose every + branch carries ``required``, whose generated module exists, and whose shape the + reader can read. Those are exactly the roots that must name their keyword, + so the test's floor is this set rather than a number from a past release. + """ + from scripts import post_generate_fixes + + found: dict[str, str] = {} + for schema_file in sorted(post_generate_fixes.SCHEMA_DIR.rglob("*.json")): + relative = schema_file.relative_to(post_generate_fixes.SCHEMA_DIR) + try: + schema = json.loads(schema_file.read_text()) + except (json.JSONDecodeError, OSError): + continue + if not isinstance(schema, dict): + continue + declared = _root_combinator(schema) + if declared is None: + continue + combinator, branches = declared + if not all( + isinstance(branch, dict) and isinstance(branch.get("required"), list) + for branch in branches + ): + continue + module = post_generate_fixes.generated_module_for(relative) + if not module.is_file(): + continue + source = module.read_text() + class_name = post_generate_fixes._root_property_class(source, schema) + if class_name is None or post_generate_fixes.flat_root_shape(schema, relative) is None: + continue + found[class_name] = combinator + return found + + def test_every_root_required_group_refusal_names_its_own_schema_combinator(): """Over the committed tree, not a fixture: each keyword matches its schema. @@ -2779,7 +2895,7 @@ def test_every_root_required_group_refusal_names_its_own_schema_combinator(): continue if not isinstance(schema, dict): continue - declared = post_generate_fixes._root_required_groups(schema) + declared = _root_combinator(schema) if declared is None: continue module = post_generate_fixes.OUTPUT_DIR.joinpath( @@ -2788,18 +2904,65 @@ def test_every_root_required_group_refusal_names_its_own_schema_combinator(): if not module.is_file(): continue source = module.read_text() - class_name = post_generate_fixes._root_generated_class(source, schema) - if class_name is None or "def _require_schema_required_group" not in source: + # The class carrying the root's properties: the flat class, or the shared + # shape where the emitter split the root into branches. A split root's + # validator lives on each BRANCH rather than on one class, and each of + # those refusals has to name the keyword too -- which is why this asks + # the module rather than one class name. + class_name = post_generate_fixes._root_property_class(source, schema) + if class_name is None: continue keyword = declared[0] - assert f"raise PydanticCustomError(\n {keyword!r}," in source, relative - assert "raise ValueError(\n '" + class_name not in source, relative + # Two mechanisms satisfy the same obligation, and the gate measures the + # obligation rather than one spelling of it. A flat root carries the + # compiled validator. A SPLIT root whose branches are purely + # requiredness carries no validator at all -- each branch's own shape + # refuses -- so its union carries the wrapper that names the keyword. + # Counting only the first dropped two roots whose refusal is stronger, + # not weaker, than the validator it replaced. + if "def _require_schema_required_group" in source: + assert re.search( + rf"raise PydanticCustomError\(\s*{re.escape(repr(keyword))},", source + ), relative + assert "raise ValueError(\n '" + class_name not in source, relative + elif re.search(rf"name_the_combinator\(\s*{re.escape(repr(keyword))}", source): + # Matched on the keyword argument alone, not on the whole call: the + # wrapper also takes the branches now, so an exact-text match for + # ``name_the_combinator('anyOf')`` silently stopped recognising two + # roots the moment that argument was added. What a refusal actually + # SAYS, on each of the four validation paths and for all nineteen + # roots with branches, is graded in + # ``tests/test_branch_dispatch_on_every_path.py``; this remains the + # source-level check that every such root has the mechanism at all. + pass + else: + continue found[class_name] = keyword - assert len(found) >= 42, found + # The basis is a DERIVED property, not a watermark. It used to read + # `len(found) >= 42`, which was what beta.3 happened to emit -- and a + # watermark stops being evidence the moment the population changes, which + # it did: admitting roots whose constraint content lives in allOf members + # took the count past 150, and a bumped literal would have recorded that + # without grading it. + # + # The property: EVERY root declaring a required-group combinator, whose + # shape the reader can read, names its combinator on refusal. Computed from + # the bundle, so a root added by a version bump is covered without an edit + # and a root that loses its coverage fails here. + expected = _roots_declaring_a_required_group() + assert expected, "the derivation selected no roots at all" + missing = sorted(set(expected) - set(found)) + assert not missing, ( + f"{len(missing)} root(s) declare a required-group combinator and name no " + f"keyword on refusal: {missing[:8]}" + ) assert set(found.values()) == {"anyOf", "oneOf"}, found - assert found["CreativeAsset"] == "oneOf", found - assert found["CreateMediaBuyRequest"] == "anyOf", found + # Two roots whose branches are purely requiredness, named outright because + # they are the ones a mechanism-shaped gate would have lost: they carry no + # validator at all, so only the union's wrapper names their keyword. + assert found["Catchment"] == "oneOf", found + assert found["FrequencyCapImpressionConstraints"] == "oneOf", found def test_a_consumer_reads_the_schema_keyword_off_the_refusal(): @@ -2825,8 +2988,17 @@ def test_a_consumer_reads_the_schema_keyword_off_the_refusal(): with pytest.raises(ValidationError) as refusal: CreateMediaBuyRequest.model_validate(media_buy) assert [error["type"] for error in refusal.value.errors()] == ["anyOf"] + # The package carries a budget, which the fixed-allocation branch requires: + # "Fixed allocation requires budget on every package". Without it the + # bundled schema refuses the document, and this assertion passed only while + # the model did not enforce the root combinator. assert CreateMediaBuyRequest.model_validate( - {**media_buy, "packages": [{"product_id": "p1", "pricing_option_id": "cpm_usd_fixed"}]} + { + **media_buy, + "packages": [ + {"product_id": "p1", "pricing_option_id": "cpm_usd_fixed", "budget": 1000.0} + ], + } ).packages # The generated class, because the public ``adcp.types.CreativeAsset`` is @@ -2852,17 +3024,19 @@ def test_schema_keyword_raise_refuses_a_keyword_outside_the_vocabulary(): consumer can tell them apart. """ from scripts import post_generate_fixes + from scripts.compile_conditions import SCHEMA_KEYWORDS - assert "oneOf" in post_generate_fixes.SCHEMA_KEYWORDS + assert "oneOf" in SCHEMA_KEYWORDS with pytest.raises(ValueError, match="not a JSON Schema keyword"): post_generate_fixes.schema_keyword_raise("mutuallyExclusive", "nope") -def test_root_required_group_leaves_per_arm_models_alone(tmp_path, monkeypatch): - """A root the generator splits into arm classes keeps no root-wide check. +def test_root_required_group_leaves_per_branch_models_alone(tmp_path, monkeypatch): + """A root the generator splits into branch classes keeps no root-wide check. - One arm's required group says nothing about another arm, and the union alias - is not a class, so there is nothing to attach a validator to. + The branches ARE the types there, so their content is already enforced, and the + union alias is not a class to attach a validator to. ``flat_root_shape`` + answers it: a root declaring no merged ``properties`` has no flat class. """ schema = { "title": "Budget Plan", @@ -2887,7 +3061,7 @@ def test_root_required_group_leaves_per_arm_models_alone(tmp_path, monkeypatch): assert target.read_text() == module_source -def test_root_required_group_resolves_the_generator_acronym_casing(tmp_path, monkeypatch): +def test_root_constraints_resolve_the_generator_acronym_casing(tmp_path, monkeypatch): """The root class name matches the schema title up to case. datamodel-code-generator title-cases an acronym, so the title @@ -2922,12 +3096,12 @@ def test_root_required_group_resolves_the_generator_acronym_casing(tmp_path, mon monkeypatch.setattr(post_generate_fixes, "SCHEMA_DIR", schema_dir) monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) - post_generate_fixes.enforce_root_required_groups() + post_generate_fixes.enforce_root_constraints() assert "def _require_schema_required_group(self) -> SiSendMessageRequest:" in target.read_text() -def test_root_required_group_skips_a_name_two_classes_share(tmp_path, monkeypatch): +def test_root_constraints_skip_a_name_two_classes_share(tmp_path, monkeypatch): """A name that resolves to two classes does not identify the root.""" schema = { "title": "Budget Plan", @@ -2951,12 +3125,19 @@ def test_root_required_group_skips_a_name_two_classes_share(tmp_path, monkeypatc assert target.read_text() == module_source -def test_root_required_group_asks_whether_the_caller_supplied_the_field(tmp_path, monkeypatch): - """A default is not a supplied value, and an explicit null is. +def test_root_constraints_ask_what_the_document_carries(tmp_path, monkeypatch): + """An explicit null is a supplied value, and so is a default the class emits. + + ``required`` asks whether the property is present on the DOCUMENT. AdCP 3.2 + mutation inputs use an explicit null to clear a stored value, so a request + whose only group is ``daily_budget_cap: null`` is a request to remove the cap. - ``required`` asks whether the property is present. AdCP 3.2 mutation inputs - use an explicit null to clear a stored value, so a request whose only group - is ``daily_budget_cap: null`` is a request to remove the cap. + And a group over a field whose default the class EMITS is satisfied by the + empty construction, because that construction's dump carries the field: the + two documents are byte-identical and a refusal would grade ``model_fields_set``, + which the wire cannot express (salesagent-k37fs). Both fixtures state the same + schema; what separates them is whether the class emits anything for + ``replayed``, which is the only thing that can decide the group. """ from pydantic import ValidationError @@ -2969,25 +3150,40 @@ def test_root_required_group_asks_whether_the_caller_supplied_the_field(tmp_path }, "anyOf": [{"required": ["daily_budget_cap"]}, {"required": ["replayed"]}], } - module_source = ( - "from __future__ import annotations\n\n" - "from pydantic import BaseModel\n\n\n" - "class BudgetControl(BaseModel):\n" - " daily_budget_cap: float | None = None\n" - " replayed: bool = True\n" - ) - - target = _write_required_group_fixture(tmp_path, monkeypatch, schema, module_source) - namespace: dict[str, object] = {} - exec(compile(target.read_text(), str(target), "exec"), namespace) # noqa: S102 - model = namespace["BudgetControl"] - cleared = model.model_validate({"daily_budget_cap": None}) + def compiled(name: str, declaration: str): + module_source = ( + "from __future__ import annotations\n\n" + "from pydantic import BaseModel\n\n\n" + "class BudgetControl(BaseModel):\n" + " daily_budget_cap: float | None = None\n" + f" {declaration}\n" + ) + # Its own directory: the fixture helper creates the tree it writes into. + root = tmp_path / name + root.mkdir() + target = _write_required_group_fixture(root, monkeypatch, schema, module_source) + namespace: dict[str, object] = {} + exec(compile(target.read_text(), str(target), "exec"), namespace) # noqa: S102 + return namespace["BudgetControl"] + + emitting = compiled("emitting", "replayed: bool = True") + cleared = emitting.model_validate({"daily_budget_cap": None}) assert cleared.daily_budget_cap is None - assert model.model_validate({"replayed": True}).replayed is True - # `replayed` carries a default the caller never sent, so its group is unsatisfied. + assert emitting.model_validate({"replayed": True}).replayed is True + # The empty construction dumps ``replayed``, so the second group is satisfied + # and the two documents are the same bytes. + accepted = emitting.model_validate({}) + assert accepted.model_dump(mode="json", exclude_none=True) == emitting.model_validate( + {"replayed": True} + ).model_dump(mode="json", exclude_none=True) + + # The same schema over a class that emits nothing for either field: there the + # group really is unsatisfied, which is what gives the check teeth. + silent = compiled("silent", "replayed: bool | None = None") + assert silent.model_validate({"replayed": False}).replayed is False with pytest.raises(ValidationError, match="at least one of these field groups"): - model.model_validate({}) + silent.model_validate({}) def test_codegen_input_order_is_total(tmp_path, monkeypatch): @@ -3035,3 +3231,148 @@ def test_codegen_input_order_is_total(tmp_path, monkeypatch): "signals/list-request.json", "bundled/creative/list-request.json", ] + + +def _capabilities_tree(root, *, publisher_annotation="list[PublisherDomain]"): + """A two-module stand-in for the capabilities views the bundle renders twice.""" + bundled = root / "bundled" / "protocol" + bundled.mkdir(parents=True) + (bundled / "get_adcp_capabilities_response.py").write_text( + "from __future__ import annotations\n" + "from typing import Annotated\n" + "from pydantic import Field\n" + "from adcp.types.base import AdCPBaseModel\n" + "from adcp.types._scalar import ScalarStr\n" + "\n\n" + "class PublisherDomain(ScalarStr):\n" + " pass\n" + "\n\n" + "class Portfolio(AdCPBaseModel):\n" + f" publisher_domains: Annotated[\n {publisher_annotation},\n" + ' Field(description="domains"),\n' + " ]\n" + " description: str | None = None\n" + ) + canonical = root / "core" + canonical.mkdir(parents=True) + (canonical / "canonical_product.py").write_text( + "from __future__ import annotations\n" + "from adcp.types._scalar import ScalarStr\n" + "\n\n" + "class PublisherDomain(ScalarStr):\n" + " pass\n" + ) + return bundled / "get_adcp_capabilities_response.py" + + +def test_compatibility_annotation_is_written_at_the_declaration_site(tmp_path, monkeypatch): + """The replacement lands on the field, with its citation and its import. + + The point of writing it here rather than patching the class is that the + nesting parent is generated source naming the generated type. Graded by + reading the field's own declaration, which is the thing a parsed document + goes through. + """ + from scripts import post_generate_fixes + + target = _capabilities_tree(tmp_path) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", tmp_path) + monkeypatch.setattr( + post_generate_fixes, + "COMPATIBILITY_ANNOTATIONS", + ( + post_generate_fixes.CompatibilityAnnotation( + module="bundled/protocol/get_adcp_capabilities_response.py", + class_name="Portfolio", + field="publisher_domains", + expected="list[PublisherDomain]", + replacement="list[_CanonicalPublisherDomain]", + imports=( + "from ...core.canonical_product import " + "PublisherDomain as _CanonicalPublisherDomain", + ), + because="The bundled view inlines what the canonical view reaches by $ref.", + ), + ), + ) + + post_generate_fixes.declare_compatibility_annotations() + + source = target.read_text() + assert " list[_CanonicalPublisherDomain],\n" in source + assert "list[PublisherDomain]" not in source + assert ( + "from ...core.canonical_product import PublisherDomain as _CanonicalPublisherDomain" + in source + ) + # The citation sits on the field, ahead of its declaration, so a reader of + # the model sees why the type is not the one beside it. + citation, _, declaration = source.partition(" publisher_domains: Annotated[") + assert citation.endswith( + " # The bundled view inlines what the canonical view reaches by $ref.\n" + ) + assert declaration # the declaration survived the insertion + # The field beside it is untouched: the rewrite is bounded to one block. + assert " description: str | None = None\n" in source + + +def test_compatibility_annotation_refuses_a_field_that_lost_its_shape(tmp_path, monkeypatch): + """A regeneration that reshapes the field fails here, not at import time. + + The runtime patch this replaced raised ``RuntimeError`` from an application + import when the generated annotation moved. Moving the guard to generation + time only helps if it still refuses, so the same tree is run through with + one annotation changed and nothing else. + """ + from scripts import post_generate_fixes + + target = _capabilities_tree(tmp_path, publisher_annotation="list[str]") + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", tmp_path) + monkeypatch.setattr( + post_generate_fixes, + "COMPATIBILITY_ANNOTATIONS", + ( + post_generate_fixes.CompatibilityAnnotation( + module="bundled/protocol/get_adcp_capabilities_response.py", + class_name="Portfolio", + field="publisher_domains", + expected="list[PublisherDomain]", + replacement="list[_CanonicalPublisherDomain]", + imports=(), + because="unused here", + ), + ), + ) + + before = target.read_text() + with pytest.raises(RuntimeError, match="which does not contain"): + post_generate_fixes.declare_compatibility_annotations() + # It refuses without writing: a partially rewritten module would be worse + # than no rewrite, because the next pass would read it as the real shape. + assert target.read_text() == before + + +def test_compatibility_annotation_refuses_a_field_that_is_gone(tmp_path, monkeypatch): + """A renamed or deleted field has no declaration site, and says so.""" + from scripts import post_generate_fixes + + _capabilities_tree(tmp_path) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", tmp_path) + monkeypatch.setattr( + post_generate_fixes, + "COMPATIBILITY_ANNOTATIONS", + ( + post_generate_fixes.CompatibilityAnnotation( + module="bundled/protocol/get_adcp_capabilities_response.py", + class_name="Portfolio", + field="publisher_domain", # singular: not the generated name + expected="list[PublisherDomain]", + replacement="list[_CanonicalPublisherDomain]", + imports=(), + because="unused here", + ), + ), + ) + + with pytest.raises(RuntimeError, match="is not declared"): + post_generate_fixes.declare_compatibility_annotations() diff --git a/tests/test_codegen_contract_compatibility.py b/tests/test_codegen_contract_compatibility.py index 4ff4ee2eb..a810c95ce 100644 --- a/tests/test_codegen_contract_compatibility.py +++ b/tests/test_codegen_contract_compatibility.py @@ -8,41 +8,6 @@ from pydantic import BaseModel, ValidationError -def test_response_dispatch_omits_newer_pydantic_keywords_at_their_defaults() -> None: - from adcp.types.response_dispatch import _model_validate_json_kwargs, _model_validate_kwargs - - assert _model_validate_kwargs( - strict=None, - extra=None, - from_attributes=None, - context=None, - by_alias=None, - by_name=None, - ) == {"strict": None, "from_attributes": None, "context": None} - assert _model_validate_kwargs( - strict=True, - extra="forbid", - from_attributes=True, - context={"trace_id": "trace_1"}, - by_alias=True, - by_name=False, - ) == { - "strict": True, - "extra": "forbid", - "from_attributes": True, - "context": {"trace_id": "trace_1"}, - "by_alias": True, - "by_name": False, - } - assert _model_validate_json_kwargs( - strict=None, - extra=None, - context=None, - by_alias=None, - by_name=None, - ) == {"strict": None, "context": None} - - def test_product_signal_targeting_option_keeps_discriminated_signal_ref() -> None: from typing import get_args @@ -58,8 +23,17 @@ def test_product_signal_targeting_option_keeps_discriminated_signal_ref() -> Non "description" ].startswith("Canonical signal reference.") + # ``signal-listing.json``'s root ``allOf`` requires ``name`` and + # ``value_type`` when ``signal_ref.scope`` is ``product``: a product-local + # listing defines the signal inline, so it has to say what the signal is + # called and what its values are. The conjunct sits beside the root + # combinator, which the generator drops and the constraint pass compiles. option = ProductSignalTargetingOption.model_validate( - {"signal_ref": {"scope": "product", "signal_id": "signal_1"}} + { + "signal_ref": {"scope": "product", "signal_id": "signal_1"}, + "name": "Signal One", + "value_type": "binary", + } ) assert isinstance(option.signal_ref, get_args(arms)) assert option.signal_ref.scope == "product" @@ -75,9 +49,9 @@ def test_product_signal_targeting_option_keeps_discriminated_signal_ref() -> Non def test_creative_representation_keeps_canonical_format_contract() -> None: - from adcp import LegacyBuildCreativeRequest from adcp.types import CanonicalFormatKind from adcp.types.domains.core.creative_representation import CreativeRepresentation + from adcp.types.domains.core.creative_representation_set import CreativeRepresentationSet # ``str``, not the enum: the vocabulary is open at every reference and # governed by a validator on the request models. Asserting membership of @@ -122,22 +96,27 @@ def test_creative_representation_keeps_canonical_format_contract() -> None: "name": "Test creative", "representations": [{**representation, "format_kind": "unknown"}], } - # ``LegacyBuildCreativeRequest`` is a request model and it too retains the - # unknown kind rather than refusing it: no model in this SDK refuses a - # format kind, because a pinned library cannot tell a kind a seller - # invented from a kind defined after its pin. That is the one cost of the - # open vocabulary, and it is declared - # by name in ``tests/conformance/_schema_parity.py``'s + # The unknown kind is retained at a NESTED position too, so the claim is + # graded rather than implied: no model in this SDK refuses a format kind, + # because a pinned library cannot tell a kind a seller invented from a kind + # defined after its pin. That is the one cost of the open vocabulary, and it + # is declared by name in ``tests/conformance/_schema_parity.py``'s # ``format_kind_is_an_open_vocabulary_by_decision`` — the bundled schema - # still refuses this document, and the model no longer agrees. Retention is - # asserted at the nested position so the claim is graded rather than implied. - built = LegacyBuildCreativeRequest.model_validate( - { - "idempotency_key": "idem-123456789012", - "creative_representation_set": representation_set, - } - ) - assert built.creative_representation_set.representations[0].format_kind == "unknown" + # still refuses this document, and the model no longer agrees. + # + # The carrier is ``CreativeRepresentationSet``, the representations' own + # parent. It used to be ``LegacyBuildCreativeRequest``, which carries SEVEN + # root conjuncts of its own: a representation-set build must also be + # ``mode: execute`` and carry ``representation_destination`` (itself a full + # product-format declaration), ``representation_selection_strategy`` and + # ``target_capability_id``, and must carry none of twenty-three other + # fields. The fixture satisfied none of that, so once + # ``root_carries_constraints`` began reading ``allOf`` MEMBERS the model + # refused the document — correctly. Satisfying those rules here would make + # the fixture an order of magnitude larger than the one field it grades, + # and the nesting it grades is the same either way. + carried = CreativeRepresentationSet.model_validate(representation_set) + assert carried.representations[0].format_kind == "unknown" def test_transformer_requires_a_canonical_or_legacy_output_declaration() -> None: @@ -192,6 +171,7 @@ def test_public_response_bases_remain_constructible_and_arms_remain_specific() - from adcp.types.domains.content_standards.update_content_standards_response import ( UpdateContentStandardsResponse1, ) + from adcp.types.validation import validate_union from adcp.utils.response_parser import parse_json_or_text responses = ( @@ -252,25 +232,43 @@ def test_public_response_bases_remain_constructible_and_arms_remain_specific() - preserved_fields, ) in responses: assert issubclass(response_base, BaseModel) - assert isinstance(response_base(), response_base) assert response_alias is response_arm assert issubclass(response_arm, response_base) assert isinstance(response_alias.model_validate(valid), response_base) + # Constructible is what the base exists for, and it stays so -- what + # changed is that construction validates. ``response_base()`` used to + # produce an empty instance of the loose base while + # ``response_base.model_validate({})`` refused the same document, which + # is the inconsistency the branch dispatch removes. + assert isinstance(response_base(**valid), response_base) + with pytest.raises(ValidationError): + response_base() + + class Holder(BaseModel): + value: response_base # type: ignore[valid-type] + direct = response_base.model_validate(valid) direct_json = response_base.model_validate_json(json.dumps(valid)) parsed = parse_json_or_text(valid, response_base) - for result in (direct, direct_json, parsed): + # The two paths a ``model_validate`` override could not reach. The + # nested one is how most of these appear in another model, and + # ``validate_union`` is the SDK's own helper over a ``TypeAdapter`` -- + # the one the server framework calls on a handler's return value. + nested = Holder.model_validate({"value": valid}).value + adapted = validate_union(response_base, valid) + for result in (direct, direct_json, parsed, nested, adapted): assert type(result) is response_arm assert all(getattr(result, name) == value for name, value in preserved_fields.items()) - empty_base = response_base() - assert response_base.model_validate(empty_base) is empty_base - with pytest.raises(ValidationError): response_alias.model_validate(invalid_arm) with pytest.raises(ValidationError): response_base.model_validate(invalid_base) + with pytest.raises(ValidationError): + Holder.model_validate({"value": invalid_base}) + with pytest.raises(ValidationError): + validate_union(response_base, invalid_base) with pytest.raises(ValueError): parse_json_or_text(invalid_base, response_base) diff --git a/tests/test_codegen_invariants.py b/tests/test_codegen_invariants.py index 36fe98aee..445372e0f 100644 --- a/tests/test_codegen_invariants.py +++ b/tests/test_codegen_invariants.py @@ -13,7 +13,6 @@ AcceptancePolicyProfile, ControlMediaBuyResponse, CreateMediaBuyRequest, - GetProductsRequest, ListProductsResponse, Product, RefineProposalsRequest, @@ -123,7 +122,19 @@ def test_responses_only_emit_replayed_when_supplied( [ (CreateMediaBuyRequest, "bidding", {"bid_amount": 5}, "automatic"), (Product, "media_buy_support", {"future_capability": True}, "frequency_cap"), - (GetProductsRequest, "required_media_buy_support", {}, "frequency_cap"), + # ProductMediaBuySupportRequirements is NOT here, and cannot be: this + # case needs a schema-valid document that OMITS the constant field, and + # core/media-buy-support-requirements.json admits none. It declares + # exactly two properties, additionalProperties false, minProperties 1, + # and dependencies {frequency_cap_constraints: [frequency_cap]} -- so + # {} is refused on minProperties, frequency_cap_constraints alone is + # refused on dependencies, and every accepted document carries + # frequency_cap. Asked of the oracle rather than reasoned: of {}, + # {frequency_cap_constraints}, {frequency_cap} and both, only + # {"frequency_cap": True} validates. The case asserted behaviour over a + # document the spec rejects, and it passed only while minProperties was + # uncompiled (salesagent-09ghh). frequency_cap's constant is still + # graded, by the Product.media_buy_support case above. ( AcceptancePolicyProfile, "scope", diff --git a/tests/test_composability_invariant.py b/tests/test_composability_invariant.py index 5a908935b..3bb2e8e1a 100644 --- a/tests/test_composability_invariant.py +++ b/tests/test_composability_invariant.py @@ -67,7 +67,14 @@ def test_capability_dicts_parse_to_canonical_public_models() -> None: assert isinstance(portfolio.primary_countries[0], PrimaryCountry) -def test_composability_patch_preserves_generated_field_constraints() -> None: +def test_composability_annotation_preserves_generated_field_constraints() -> None: + """Swapping the type must not take the constraints beside it. + + ``COMPATIBILITY_ANNOTATIONS`` rewrites the type INSIDE the generated + ``Annotated[...]``, leaving the ``Field(...)`` untouched. The runtime patch + this replaced rebuilt a ``FieldInfo`` and had to copy the old one to keep + ``min_length``; a source rewrite bounded to the type cannot drop it. + """ with pytest.raises(ValidationError, match="at least 1 item"): Portfolio(publisher_domains=[]) @@ -83,7 +90,7 @@ def test_composability_patch_preserves_generated_field_constraints() -> None: def test_composed_model_classes_have_equivalent_wire_schemas( canonical_model: type, bundled_model: type ) -> None: - """A schema drift must be reviewed before an identity patch remains valid. + """A schema drift must be reviewed before the declared equivalence holds. ``TypeAdapter`` rather than ``model_json_schema()``: the scalar schemas in this list are ``str`` subclasses, not Pydantic models — see #1277. diff --git a/tests/test_conditions_vocabulary.py b/tests/test_conditions_vocabulary.py new file mode 100644 index 000000000..c38084ac6 --- /dev/null +++ b/tests/test_conditions_vocabulary.py @@ -0,0 +1,763 @@ +"""One test per primitive in the compiled-condition vocabulary. + +These are the exception the design names to "no unit tests for behaviour a +parity case can grade": the primitives are pure functions over one value, the +parity corpus grades their COMPOSITION on real documents, and the two +conventions they share -- presence as what the DOCUMENT carries, and vacuity +off-type -- are exactly where a semantic slip between a compiled predicate and +JSON Schema would land first. + +Each test states the keyword the primitive carries, the satisfied case, the +refused case, and the vacuous case. A primitive with no vacuity to state says +so. +""" + +from __future__ import annotations + +import datetime +import decimal +import inspect +import ipaddress +import types +import uuid +from typing import Any + +import pytest +from pydantic import AnyUrl, BaseModel, ConfigDict, model_serializer +from pydantic_core.core_schema import SerializerFunctionWrapHandler + +from adcp.types import _conditions as c + + +class _Nested(BaseModel): + mode: str | None = None + scale: int | None = None + + +class _Subject(BaseModel): + """A model whose defaults are deliberately not ``None``. + + ``pacing`` is what separates the three readings of presence: it is not + ``None``, the caller never sent it, and the document carries it anyway + because this class emits it. The last is the one the schema asks about. + """ + + packages: list[str] | None = None + proposal_id: str | None = None + pacing: str = "even" + nested: _Nested | None = None + + +class _Dropping(BaseModel): + """A model whose serializer drops the unset default, so the wire lacks it. + + The shape of ``GetMediaBuyDeliveryRequest``, which defaults two booleans and + pops them when unset. Here the default exists and the document does NOT + carry it, so presence must come out False -- which is why the primitives ask + the serializer instead of inspecting the default. + """ + + tier: str = "standard" + reason: str | None = None + + @model_serializer(mode="wrap") + def _drop_unset_tier(self, handler: SerializerFunctionWrapHandler) -> Any: + value = handler(self) + if "tier" not in self.model_fields_set: + value.pop("tier", None) + return value + + +class _Open(BaseModel): + model_config = ConfigDict(extra="allow") + + declared: int | None = None + + +def test_supplied_is_what_the_document_carries_not_a_null_check() -> None: + """``required``: the document carried the property. + + The cases that separate it from every other spelling: an explicit null IS + supplied, an absent nullable field is not, and a default the class EMITS is + -- because the dump carries it whether or not the caller typed it, so a + ``required`` over it is satisfied either way (salesagent-k37fs). + + The last clause is the serializer's answer, not the default's: ``_Dropping`` + has the same non-None default and pops it when unset, and there the document + really is missing it. + """ + subject = _Subject.model_validate({"packages": ["pkg_1"], "proposal_id": None}) + + assert c.supplied(subject, "packages") + assert c.supplied(subject, "proposal_id"), "an explicit null is a supplied value" + assert c.supplied(subject, "pacing"), "a default the class emits is on the document" + assert not c.supplied(subject, "nested") + + assert not c.supplied(_Dropping(), "tier"), "a default the serializer drops is not carried" + assert c.supplied(_Dropping(tier="standard"), "tier"), "the same value, typed, is" + + +def test_sent_is_membership_plus_the_non_null_half() -> None: + """Presence on the document that is SENT, for a position forbidding null. + + ``supplied`` is membership and is the right reading where the schema marks + the position nullable. Where it does not, the client's ``exclude_none`` + drops a null-valued key and ``preserve_explicit_nulls`` restores only + nullable paths, so the document that arrives does not carry it -- + ``control-media-buy-request``'s ``canceled`` is ``Literal[True]`` and + ``canceled=None`` is dropped on the way out. Reading it as present refused + a request no seller receives. + + It adds only that half: whether the document carries the name at all is + :func:`c.supplied`'s question, and a default the class emits is carried. + """ + subject = _Subject.model_validate({"packages": ["pkg_1"], "proposal_id": None}) + + assert c.sent(subject, "packages") + assert not c.sent(subject, "proposal_id"), "an explicit null is not sent at a closed position" + assert c.supplied(subject, "proposal_id"), "and supplied still answers presence" + assert c.sent(subject, "pacing"), "a default the class emits reaches the seller" + assert not c.sent(_Dropping(), "tier"), "one its serializer drops does not" + assert not c.sent("not an object", "packages") + + +def test_absent_refuses_a_key_the_shape_forbids_and_passes_everything_else() -> None: + """``not: {required: [x]}`` on a branch class, at wire validation. + + A branch class types a field its arm forbids as ``None``, which is what + makes the combination unspellable at construction. Under ``extra='allow'`` + that is not what refuses a raw document, so the forbidden names are refused + here -- and presence is read the way ``sent`` reads it, because the negation + of ``required`` is about the same document. At a position the schema forbids + null, the client drops a null-valued key, so only a non-null value is + carried; at a nullable position a null survives and its presence is what + the ban forbids. A document carrying none of them, and anything that is not + a mapping, pass through unchanged. + """ + assert c.absent({"packages": ["pkg_1"]}, ["proposal_id"]) == {"packages": ["pkg_1"]} + assert c.absent("not an object", ["proposal_id"]) == "not an object" + assert c.absent({"proposal_id": None}, ["proposal_id"]) == { + "proposal_id": None + }, "a null at a position forbidding null never reaches the document sent" + + with pytest.raises(ValueError, match="proposal_id is not accepted"): + c.absent({"packages": [], "proposal_id": "prop_1"}, ["proposal_id"]) + + with pytest.raises(ValueError, match="daily_budget_cap is not accepted"): + c.absent({"daily_budget_cap": None}, [], ["daily_budget_cap"]) + + with pytest.raises(ValueError, match="budget_allocation, packages"): + c.absent({"packages": [1], "budget_allocation": {}}, ["packages", "budget_allocation"]) + + +def test_supplied_reads_a_mapping_by_its_keys() -> None: + """An open payload reaches a predicate as a mapping, not a model.""" + assert c.supplied({"a": None}, "a") + assert not c.supplied({"a": None}, "b") + assert not c.supplied("not an object", "a") + + +def test_descend_is_vacuous_for_a_member_the_document_did_not_carry() -> None: + """``properties`` grades present object members only -- including a null one. + + An arm constraining ``nested.mode`` says nothing about a request that sent + no ``nested`` at all. It does say something about one that sent ``null``: + JSON Schema applies the subschema to whatever value is there, and a + subschema expecting an object is false for ``null``. Reading the null as + vacuous instead let ``creative-manifest.json``'s audio-VAST conjunct fire + on a manifest whose ``format_kind`` was null, refusing a document under a + rule the schema does not apply to it. + """ + predicate = lambda value: getattr(value, "mode", None) == "fixed" # noqa: E731 + + assert c.descend(_Subject.model_validate({"nested": {"mode": "fixed"}}), "nested", predicate) + assert not c.descend( + _Subject.model_validate({"nested": {"mode": "seller_optimized"}}), "nested", predicate + ) + assert c.descend(_Subject(), "nested", predicate), "absent is unconstrained" + assert not c.descend( + _Subject.model_validate({"nested": None}), "nested", predicate + ), "an explicit null is a value the subschema grades, and this one fails it" + # And a predicate JSON Schema reads as vacuous off-type stays vacuous on a + # null, which is what keeps the composition faithful rather than strict. + assert c.descend( + _Subject.model_validate({"nested": None}), "nested", lambda value: c.matches(value, "^x") + ) + + +def test_every_grades_each_element_and_nothing_else() -> None: + """``items``.""" + assert c.every([2, 4], lambda value: value % 2 == 0) + assert not c.every([2, 3], lambda value: value % 2 == 0) + assert c.every([], lambda value: False), "an empty array satisfies items" + assert c.every("not an array", lambda value: False) + + +def test_some_grades_whether_one_element_matches() -> None: + """``contains``.""" + assert c.some([1, 2], lambda value: value == 2) + assert not c.some([1, 3], lambda value: value == 2) + assert not c.some([], lambda value: True), "an empty array contains nothing" + assert c.some(None, lambda value: False) + + +def test_keys_grades_the_member_names() -> None: + """``propertyNames``.""" + assert c.keys({"ab": 1, "ac": 2}, lambda name: name.startswith("a")) + assert not c.keys({"ab": 1, "zz": 2}, lambda name: name.startswith("a")) + assert c.keys(7, lambda name: False) + + +def test_matching_keys_grades_only_the_matching_members() -> None: + """``patternProperties``.""" + assert c.matching_keys({"x_a": 1, "other": "text"}, "^x_", lambda value: value == 1) + assert not c.matching_keys({"x_a": 2, "other": "text"}, "^x_", lambda value: value == 1) + assert c.matching_keys({"other": "text"}, "^x_", lambda value: False) + + +def test_only_bounds_what_the_document_carries() -> None: + """``additionalProperties: false``. + + The bound is on what the document carried, not on what the class declares: + a class normally has more fields than one arm's ``properties`` names, and a + field the caller left out is not an additional property -- unless the class + emits its default, which puts it on the document and so within the bound. + """ + assert c.only(_Subject.model_validate({"packages": []}), ["packages", "pacing"]) + assert not c.only( + _Subject.model_validate({"packages": []}), ["packages"] + ), "pacing's emitted default is a member the bound has to name" + assert not c.only(_Subject.model_validate({"packages": [], "proposal_id": "p"}), ["packages"]) + assert not c.only( + _Open.model_validate({"declared": 1, "undeclared": 2}), ["declared"] + ), "a key the model carried through is supplied" + assert c.only("not an object", []) + + +def test_others_grades_the_unnamed_members() -> None: + """``additionalProperties: <schema>``, which is how a map type is spelled.""" + assert c.others({"a": "pass", "b": "fail"}, [], lambda value: value in {"pass", "fail"}) + assert not c.others({"a": "pass", "b": "x"}, [], lambda value: value in {"pass", "fail"}) + assert c.others({"a": "x"}, ["a"], lambda value: False), "a named member is exempt" + + +@pytest.mark.parametrize( + ("value", "names", "expected"), + [ + ("text", ("string",), True), + (1, ("string",), False), + (True, ("integer",), False), + (True, ("boolean",), True), + (1, ("number",), True), + (1.5, ("integer",), False), + ([], ("array",), True), + ({}, ("object",), True), + (_Subject(), ("object",), True), + (None, ("null",), True), + ("text", ("integer", "string"), True), + # A compiled ``type`` check runs AFTER validation, so the value it sees + # is what pydantic parsed, not what the document carried. Every one of + # these arrived as a JSON string. + (datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), ("string",), True), + (datetime.date(2026, 1, 1), ("string",), True), + (datetime.timedelta(seconds=5), ("string",), True), + (uuid.UUID(int=1), ("string",), True), + (AnyUrl("https://seller.example"), ("string",), True), + (ipaddress.IPv4Address("10.0.0.1"), ("string",), True), + # And a JSON number can arrive as a Decimal, which is neither int nor + # float -- so defining "string" by excluding the other five JSON types + # would call this one a string. + (decimal.Decimal("2.5"), ("number",), True), + (decimal.Decimal("2.5"), ("string",), False), + (decimal.Decimal("2.5"), ("integer",), False), + (decimal.Decimal("2"), ("integer",), True), + ], +) +def test_json_type_answers_the_json_type_names( + value: Any, names: tuple[str, ...], expected: bool +) -> None: + """``type``, including the Python-versus-JSON mismatches. + + ``bool`` is a subclass of ``int`` in Python and a separate type in JSON, and + a pydantic model is a JSON object without being a mapping. + + The third mismatch is the one that shipped broken: a ``format``-bearing + string field is a ``datetime`` or an ``AnyUrl`` by the time the check runs, + so an ``isinstance(value, str)`` test is false for every document and the + model refuses what its schema accepts. Seven fields over six modules were in + that state on the pin -- ``reporting-revision``'s ``data_through`` among + them, which made ``tests/test_reporting_consumer_loop.py`` fail at + COLLECTION. + """ + assert c.json_type(value, *names) is expected + + +def test_matches_is_an_unanchored_search() -> None: + """``pattern``: JSON Schema specifies a partial match, not a full one.""" + assert c.matches("prefix-mid-suffix", "mid") + assert not c.matches("prefix", "mid") + assert c.matches(7, "mid"), "pattern says nothing about a non-string" + + +@pytest.mark.parametrize( + ("value", "bounds", "expected"), + [ + ("abc", {"minimum": 3}, True), + ("ab", {"minimum": 3}, False), + ([1, 2, 3], {"maximum": 2}, False), + ([1, 2], {"maximum": 2}, True), + ({"a": 1}, {"minimum": 1, "maximum": 1}, True), + ({}, {"minimum": 1}, False), + (7, {"minimum": 1}, True), + ], +) +def test_length_bounds_anything_sized_and_nothing_else( + value: Any, bounds: dict[str, int], expected: bool +) -> None: + """``minLength``/``maxLength``, ``minItems``/``maxItems``, ``min``/``maxProperties``.""" + assert c.length(value, **bounds) is expected + + +@pytest.mark.parametrize( + ("value", "bounds", "expected"), + [ + (0, {"ge": 0}, True), + (-1, {"ge": 0}, False), + (0, {"gt": 0}, False), + (100, {"le": 100}, True), + (101, {"le": 100}, False), + (1, {"lt": 1}, False), + (True, {"ge": 5}, True), + ("5", {"ge": 5}, True), + ], +) +def test_within_bounds_only_numbers(value: Any, bounds: dict[str, float], expected: bool) -> None: + """``minimum``/``maximum``/``exclusiveMinimum``/``exclusiveMaximum``. + + ``True`` is excluded on purpose: it is an ``int`` subclass in Python and a + boolean in JSON, so a numeric bound says nothing about it. + """ + assert c.within(value, **bounds) is expected + + +def test_unique_compares_unhashable_elements() -> None: + """``uniqueItems``, over the array of objects that is the common case.""" + assert c.unique([{"a": 1}, {"a": 2}]) + assert not c.unique([{"a": 1}, {"a": 1}]) + assert c.unique([]) + assert c.unique("not an array") + + +def test_members_counts_what_the_document_carried_not_what_the_model_declares() -> None: + """``minProperties``/``maxProperties``, which ``length`` cannot measure. + + A pydantic object has no ``__len__``, so routing these two through + :func:`c.length` sent every object down its ``TypeError`` branch and + returned True -- the validator existed and graded nothing. The count is + ``model_fields_set`` plus the extras a model kept plus the defaults the + class emits, which is the question :func:`c.supplied` asks, counted instead + of tested. + + That third source is ``minProperties`` stated over a class with an + ergonomic default: ``core/deadline-policy.json`` asks for one member and + ``DeadlinePolicy`` emits ``business_days_only``, so the empty construction + carries one and the count had read nothing (salesagent-k37fs). + + The open-model case is here because it is where this went wrong a second + time. pydantic records a kept extra in BOTH ``model_fields_set`` and + ``model_extra``, so naming them by concatenation counted every extra twice + and ``maxProperties`` refused documents the schema accepts. + """ + subject = _Subject.model_validate({"packages": ["pkg_1"], "proposal_id": None}) + + assert c.members(subject, minimum=3), "packages, proposal_id, and pacing's emitted default" + assert not c.members(subject, minimum=4) + assert c.members(subject, maximum=3) + assert not c.members(subject, maximum=2) + assert c.members(_Dropping(), maximum=0), "a default the serializer drops is not a member" + + open_subject = _Open.model_validate({"declared": 1, "undeclared": 2}) + assert c.members(open_subject, minimum=2), "an extra the model kept is a member" + assert not c.members(open_subject, minimum=3), "but it is ONE member, not two" + assert c.members(open_subject, maximum=2) + + assert c.members({"a": 1, "b": 2}, minimum=2), "a mapping answers from its keys" + assert not c.members({}, minimum=1) + assert c.members("not an object", minimum=5), "vacuous off its own type" + + +def test_every_primitive_the_vocabulary_exports_is_graded_here() -> None: + """A primitive the generator can emit but nothing grades would be a hole. + + Both halves are derived: the export list against what the module defines, + and the export list against which primitives the tests above actually call. + """ + defined = { + name + for name, value in vars(c).items() + if not name.startswith("_") + and isinstance(value, types.FunctionType) + and value.__module__ == c.__name__ + } + # Both populations are asserted non-empty BEFORE they are compared. A set + # equality whose sides can both go empty grades nothing on that axis, and + # this one has already been empty: a pytest plugin that wrapped each + # primitive rebound ``__module__`` to its own module, every entry failed the + # module test, and ``defined`` came out as ``set()``. It was caught only + # because ``__all__`` happened to be non-empty at the time. + assert defined, ( + "the derivation found no primitives in " + f"{c.__name__} at all, so comparing it against __all__ would grade " + "nothing. Something has rebound them -- check __module__ on a few: a " + "wrapper that is not functools.wraps-transparent looks exactly like this" + ) + assert c.__all__, "__all__ is empty, so both halves below are vacuous" + assert defined == set(c.__all__) + graded = { + name + for test in globals() + if test.startswith("test_") + for name in c.__all__ + if f"c.{name}(" in inspect.getsource(globals()[test]) + } + assert graded == set(c.__all__), sorted(set(c.__all__) - graded) + + +def test_name_the_combinator_names_the_keyword_and_keeps_the_fields() -> None: + """The wrapper a split root's union carries. + + A document that satisfies no branch fails each branch at FIELD validation, + so pydantic reports ``missing`` and nothing names the root's combinator. + ``core/error.json`` requires a keyword drawn from the JSON Schema + vocabulary, so the wrapper supplies one -- and carries the branch TITLES in + the message and the absent field names beside them in the error context, so + the refusal says more than the keyword alone. + + The sentence is the one a flat root's compiled ``_require_schema_required_group`` + validator writes, so a root moving between the two mechanisms does not + change what a reader sees. + """ + from typing import Annotated, TypeAlias + + from pydantic import BaseModel, TypeAdapter, ValidationError, WrapValidator + + class Shape(BaseModel): + a: str | None = None + b: str | None = None + + class First(Shape): + """Carries a.""" + + a: str + b: None = None + + class Second(Shape): + """Carries b.""" + + b: str + a: None = None + + Wrapped: TypeAlias = Annotated[ + First | Second, WrapValidator(c.name_the_combinator("anyOf", (First, Second))) + ] + adapter: TypeAdapter[First | Second] = TypeAdapter(Wrapped) + + with pytest.raises(ValidationError) as refusal: + adapter.validate_python({}) + errors = refusal.value.errors() + assert [error["type"] for error in errors] == ["anyOf"] + assert errors[0]["ctx"] == {"groups": "Carries a | Carries b", "fields": "a, b"} + assert "requires at least one of these field groups: Carries a | Carries b" in errors[0]["msg"] + + # With no branches to title, the absent field names stand in for the groups + # rather than leaving the sentence with an empty list. The emitters all pass + # the branches; a hand-written wrapper may not. + bare: TypeAlias = Annotated[First | Second, WrapValidator(c.name_the_combinator("anyOf"))] + with pytest.raises(ValidationError) as untitled: + TypeAdapter(bare).validate_python({}) + assert untitled.value.errors()[0]["ctx"] == {"groups": "a, b", "fields": "a, b"} + + # The success path is untouched: a document that satisfies a branch comes + # back as that branch rather than as the base. + assert type(adapter.validate_python({"a": "x"})) is First + + # A refusal that is not about an absent field passes through unchanged, so + # the wrapper never relabels a failure it did not diagnose. + with pytest.raises(ValidationError) as other: + adapter.validate_python({"a": 1, "b": 2}) + assert "anyOf" not in {error["type"] for error in other.value.errors()} + + +def test_name_the_combinator_keeps_a_branchs_content_failure() -> None: + """A branch that passed its required fields owns the refusal. + + The wrapper names the combinator only when the document satisfies NO + branch's requiredness. If one branch got past its required fields and then + failed on content -- a bound, a pattern, a nested rule -- that failure is + the informative one, and replacing it with ``anyOf`` would discard the only + error telling the caller what is wrong. Pydantic re-wraps a raise from + inside a validator, so the original is not recoverable afterwards either. + """ + from typing import Annotated, TypeAlias + + from pydantic import BaseModel, Field, TypeAdapter, ValidationError, WrapValidator + + class Shape(BaseModel): + a: str | None = None + b: str | None = None + + class First(Shape): + a: Annotated[str, Field(min_length=5)] + + class Second(Shape): + b: str + + Wrapped: TypeAlias = Annotated[First | Second, WrapValidator(c.name_the_combinator("anyOf"))] + adapter: TypeAdapter[First | Second] = TypeAdapter(Wrapped) + + # ``a`` is supplied, so First passes requiredness and fails on the bound; + # Second reports ``b`` missing. The bound is what the caller needs. + with pytest.raises(ValidationError) as refusal: + adapter.validate_python({"a": "xy"}) + types = {error["type"] for error in refusal.value.errors()} + assert "string_too_short" in types, refusal.value.errors() + assert "anyOf" not in types, "a branch's content failure was replaced by the keyword" + + # And a document satisfying no branch's requiredness still names the keyword. + with pytest.raises(ValidationError) as none_matched: + adapter.validate_python({}) + assert [error["type"] for error in none_matched.value.errors()] == ["anyOf"] + + +def test_a_branch_that_forbids_a_carried_key_is_not_the_branch_the_document_meant() -> None: + """``_c.absent`` firing is the opposite of getting past one's requiredness. + + ``absent`` is a ``mode="before"`` validator, so a branch forbidding a key the + document carries raises AHEAD of field validation and reports no requiredness + error at all. The election rule used to test requiredness alone, so that + silence read as "this branch got past its required fields" and the branch the + document meant LEAST owned the refusal -- stating that a key is unacceptable + when it is exactly what the sibling branch requires (salesagent-klblw). + + THE DOCUMENT HERE IS THE POINT. It carries ``value``, which ``Value`` + requires and ``Range`` forbids, and omits ``dimension``, which both require. + An EMPTY document cannot grade this: ``absent`` never fires, so no branch is + elected and the keyword is named correctly either way. Any probe for this + defect has to satisfy one branch PARTIALLY. + """ + from typing import Annotated, TypeAlias + + from pydantic import BaseModel, TypeAdapter, ValidationError, WrapValidator, model_validator + + class Shape(BaseModel): + dimension: str | None = None + value: int | None = None + range: int | None = None + + class Value(Shape): + dimension: str + value: int + + @model_validator(mode="before") + @classmethod + def _refuse(cls, data: Any) -> Any: + return c.absent(data, ["range"]) + + class Range(Shape): + dimension: str + range: int + + @model_validator(mode="before") + @classmethod + def _refuse(cls, data: Any) -> Any: + return c.absent(data, ["value"]) + + Wrapped: TypeAlias = Annotated[Value | Range, WrapValidator(c.name_the_combinator("anyOf"))] + adapter: TypeAdapter[Value | Range] = TypeAdapter(Wrapped) + + with pytest.raises(ValidationError) as refusal: + adapter.validate_python({"value": 1}) + errors = refusal.value.errors() + assert [error["type"] for error in errors] == ["anyOf"], errors + # The sentence that used to come out is a statement about the one key the + # document got RIGHT, so it is named here rather than only excluded by type. + assert "value is not accepted" not in str(refusal.value), str(refusal.value) + + # A key BOTH branches forbid leaves no field group to report, and then the + # forbidden-key refusal is the informative one and is kept -- with a ``loc`` + # that is a path in the document, not pydantic's choice label. + class OnlyValue(Shape): + dimension: str + + @model_validator(mode="before") + @classmethod + def _refuse(cls, data: Any) -> Any: + return c.absent(data, ["range"]) + + Both: TypeAlias = Annotated[ + OnlyValue | OnlyValue, WrapValidator(c.name_the_combinator("anyOf")) + ] + both: TypeAdapter[OnlyValue] = TypeAdapter(Both) + with pytest.raises(ValidationError) as forbidden: + both.validate_python({"dimension": "d", "range": 1}) + kept = forbidden.value.errors() + assert {error["type"] for error in kept} == {"not"}, kept + assert all(error["loc"] == () for error in kept), [error["loc"] for error in kept] + + +def test_branch_title_reads_the_arm_title_off_the_branchs_docstring() -> None: + """The one place a branch's schema-derived text lives. + + The emitter opens each branch class's docstring with the arm's ``title``, + which is why no identifier has to be invented to carry it. A trailing period + is dropped because the title is interpolated mid-sentence, and a branch with + no docstring falls back to its class name -- which is itself derived from the + same title, so the sentence stays readable rather than empty. + """ + + class Titled: + """Explicit packages with fixed allocation. + + The rest of the docstring is the emitter's explanation and is not part + of the title. + """ + + class Undocumented: + pass + + assert c.branch_title(Titled) == "Explicit packages with fixed allocation" + assert c.branch_title(Undocumented) == "Undocumented" + + +def _counting_fallback(monkeypatch: pytest.MonkeyPatch) -> list[int]: + """Count calls to the degenerate-label fallback without changing its answer. + + Whether it FIRED is the only way to tell the two paths apart from outside: + both end in the same restated document error, and the labels the grouping + reads are gone from that result by design. + """ + calls: list[int] = [] + original = c._errors_of_the_reached_branch + + def counting(value: Any, branches: Any, merged: Any) -> Any: + calls.append(1) + return original(value, branches, merged) + + monkeypatch.setattr(c, "_errors_of_the_reached_branch", counting) + return calls + + +def test_name_the_combinator_survives_degenerate_branch_labels( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A branch's content failure survives when every label is the SAME string. + + The wrapper groups a union's errors by their first ``loc`` element to decide + which branch the document reached. That assumes pydantic gives each choice a + DISTINCT label, which nobody guarantees: a branch carrying a WRAP validator + is spelled ``function-after[..., function-wrap[<validator>()]]`` -- a name + with no class in it -- so sibling branches inheriting one wrap collapse to a + single label and a single bucket. The bucket then holds one branch's + ``missing`` beside another's content failure, every candidate looks + unreached, and the combinator keyword displaces the informative error. + + Not hypothetical: making the canonical layer's legacy-identity refusal + ``mode="wrap"`` did exactly this and took 23 tests in + ``tests/test_targeting_overlay_errors.py`` from passing to a root-level + ``anyOf`` where a field error belonged. + """ + from typing import Annotated, TypeAlias + + from pydantic import ( + BaseModel, + Field, + TypeAdapter, + ValidationError, + WrapValidator, + model_validator, + ) + + calls = _counting_fallback(monkeypatch) + + class Shape(BaseModel): + a: str | None = None + b: str | None = None + + @model_validator(mode="wrap") + @classmethod + def _collapses_the_label(cls, value: Any, handler: Any) -> Any: + # Inherited by both branches, so both are spelled by THIS wrapper + # and neither name carries its own class. + return handler(value) + + class First(Shape): + a: Annotated[str, Field(min_length=5)] + + class Second(Shape): + b: str + + Wrapped: TypeAlias = Annotated[ + First | Second, WrapValidator(c.name_the_combinator("anyOf", (First, Second))) + ] + adapter: TypeAdapter[First | Second] = TypeAdapter(Wrapped) + + # First passes requiredness and fails on the bound; Second reports b missing. + with pytest.raises(ValidationError) as refusal: + adapter.validate_python({"a": "xy"}) + + # The fixture really is degenerate, asserted rather than assumed: the + # fallback runs ONLY when one bucket covers several branches. If a future + # pydantic spells a wrapped choice with its class name this goes to zero and + # the test stops exercising the case it is named for. + assert calls == [1], "the fixture is no longer degenerate -- the fallback never ran" + + types = {error["type"] for error in refusal.value.errors()} + assert "string_too_short" in types, refusal.value.errors() + assert "anyOf" not in types, "a branch's content failure was replaced by the keyword" + # And the reported path is the DOCUMENT's, with no label left on it. + assert [tuple(error["loc"]) for error in refusal.value.errors()] == [("a",)] + + # A document satisfying no branch is still refused by the keyword. + with pytest.raises(ValidationError) as none_matched: + adapter.validate_python({}) + assert [error["type"] for error in none_matched.value.errors()] == ["anyOf"] + + +def test_the_degenerate_label_fallback_does_not_fire_when_labels_differ( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With distinct labels the grouping decides and the fallback never runs. + + Asserted on whether it was CALLED, not on the result. A fallback that fired + unconditionally would satisfy every other test here while replacing the + grouping it exists to back up, so the result alone cannot tell the two + apart -- which is the failure mode this pair is about. Same models as the + sibling above, minus the wrap validator that collapses the labels. + """ + from typing import Annotated, TypeAlias + + from pydantic import BaseModel, Field, TypeAdapter, ValidationError, WrapValidator + + calls = _counting_fallback(monkeypatch) + + class Shape(BaseModel): + a: str | None = None + b: str | None = None + + class First(Shape): + a: Annotated[str, Field(min_length=5)] + + class Second(Shape): + b: str + + Wrapped: TypeAlias = Annotated[ + First | Second, WrapValidator(c.name_the_combinator("anyOf", (First, Second))) + ] + adapter: TypeAdapter[First | Second] = TypeAdapter(Wrapped) + + with pytest.raises(ValidationError) as refusal: + adapter.validate_python({"a": "xy"}) + + assert calls == [], "the degenerate-label fallback fired where the grouping was sufficient" + assert "string_too_short" in {error["type"] for error in refusal.value.errors()} + assert [tuple(error["loc"]) for error in refusal.value.errors()] == [("a",)] diff --git a/tests/test_decisioning_async_discovery.py b/tests/test_decisioning_async_discovery.py index 4aa28a69b..5527de5e3 100644 --- a/tests/test_decisioning_async_discovery.py +++ b/tests/test_decisioning_async_discovery.py @@ -149,7 +149,7 @@ async def test_get_products_handoff_returns_submitted_envelope(executor) -> None from adcp.types import GetProductsRequest, GetProductsResponse async def _curate(task_ctx): - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") class _Platform(DecisioningPlatform): capabilities = DecisioningCapabilities() @@ -182,7 +182,7 @@ async def _curate(task_ctx): completed.set() # Empty products list is wire-valid; the terminal artifact shape is # what matters for the registry-completion assertion. - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") class _Platform(DecisioningPlatform): capabilities = DecisioningCapabilities() @@ -212,7 +212,7 @@ async def test_get_signals_handoff_returns_submitted_envelope(executor) -> None: from adcp.types import GetSignalsRequest, GetSignalsResponse async def _discover(task_ctx): - return GetSignalsResponse(signals=[]) + return GetSignalsResponse(signals=[], cache_scope="public") class _Platform(DecisioningPlatform): capabilities = DecisioningCapabilities() @@ -239,7 +239,7 @@ async def test_get_signals_handoff_completion_lands_in_registry(executor) -> Non async def _discover(task_ctx): completed.set() - return GetSignalsResponse(signals=[]) + return GetSignalsResponse(signals=[], cache_scope="public") class _Platform(DecisioningPlatform): capabilities = DecisioningCapabilities() @@ -279,7 +279,7 @@ class _Platform(DecisioningPlatform): accounts = SingletonAccounts(account_id="seller") async def get_products(self, req, ctx): - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = _make_handler(_Platform(), executor) resp = await handler.get_products( @@ -300,7 +300,7 @@ class _Platform(DecisioningPlatform): accounts = SingletonAccounts(account_id="signals-seller") async def get_signals(self, req, ctx): - return GetSignalsResponse(signals=[]) + return GetSignalsResponse(signals=[], cache_scope="public") handler = _make_handler(_Platform(), executor) resp = await handler.get_signals( @@ -359,7 +359,7 @@ def _store_backed_platform(store: _RecordingStore, handoff: bool): _products: list[Any] = [] async def _curate(task_ctx): - return GetProductsResponse(products=_products, proposals=_proposals) + return GetProductsResponse(products=_products, proposals=_proposals, cache_scope="public") class _Platform(DecisioningPlatform): capabilities = DecisioningCapabilities() @@ -371,7 +371,9 @@ def proposal_store_for_tenant(self, tenant_id: str): async def get_products(self, req, ctx): if handoff: return ctx.handoff_to_task(_curate) - return GetProductsResponse(products=_products, proposals=_proposals) + return GetProductsResponse( + products=_products, proposals=_proposals, cache_scope="public" + ) return _Platform() @@ -560,7 +562,7 @@ async def get_products(self, req, ctx): call_count["n"] += 1 from adcp.types import GetProductsResponse - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = _make_handler(_Platform(), executor) with pytest.raises(AdcpError) as exc: @@ -589,7 +591,7 @@ class _Platform(DecisioningPlatform): async def get_signals(self, req, ctx): from adcp.types import GetSignalsResponse - return GetSignalsResponse(signals=[]) + return GetSignalsResponse(signals=[], cache_scope="public") handler = _make_handler(_Platform(), executor) with pytest.raises(AdcpError) as exc: @@ -755,7 +757,9 @@ def proposal_store_for_tenant(self, tenant_id: str): return store async def get_products(self, req, ctx): - return GetProductsResponse(products=rich_products, proposals=proposals) + return GetProductsResponse( + products=rich_products, proposals=proposals, cache_scope="public" + ) store = _RecordingStore() handler = _make_handler(_Platform(), executor) @@ -797,7 +801,7 @@ async def test_get_products_handoff_unresolved_account_no_push_rejected(executor from adcp.types import GetProductsRequest, GetProductsResponse async def _curate(task_ctx): - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") class _Platform(DecisioningPlatform): capabilities = DecisioningCapabilities() @@ -836,7 +840,7 @@ async def test_wholesale_handoff_rejected_leaves_no_registry_row(executor) -> No async def _curate(task_ctx): ran["bg"] = True - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") class _Platform(DecisioningPlatform): capabilities = DecisioningCapabilities() @@ -893,35 +897,32 @@ async def get_products(self, req, ctx): assert enqueued is False -@pytest.mark.asyncio -async def test_get_signals_wholesale_handoff_rejected_leaves_no_registry_row(executor) -> None: - from adcp.types import GetSignalsRequest, GetSignalsResponse +def test_get_signals_wholesale_with_a_selector_has_no_spelling_to_hand_a_handler() -> None: + """The combination cannot reach a handler, because it cannot be constructed. - ran = {"bg": False} + ``get-signals-request.json``'s root conditional says that in wholesale + discovery mode the request carries no selector: ``if discovery_mode is + wholesale then not (signal_spec or signal_refs or signal_ids)``. This test + used to build that request, hand it to the handler, and assert the handler + refused it with ``INVALID_REQUEST`` on ``discovery_mode`` and left no + registry row -- which is what the SDK did while the conditional was dropped + from the generated model entirely. - async def _discover(task_ctx): - ran["bg"] = True - return GetSignalsResponse(signals=[]) + It is now refused where the schema states it: at construction. So the + obligation is the refusal's existence and its subject, and the handler path + it used to cover is unreachable rather than untested. Nothing here reaches + past the refusal to exercise it: a request built through + ``model_construct`` would be a document no buyer can send. + """ + from pydantic import ValidationError - class _Platform(DecisioningPlatform): - capabilities = DecisioningCapabilities() - accounts = SingletonAccounts(account_id="signals-seller") + from adcp.types import GetSignalsRequest - async def get_signals(self, req, ctx): - return ctx.handoff_to_task(_discover) + with pytest.raises(ValidationError, match="get-signals-request.json"): + GetSignalsRequest(discovery_mode="wholesale", signal_spec="x") - registry = InMemoryTaskRegistry() - handler = _make_handler(_Platform(), executor, registry=registry) - with pytest.raises(AdcpError) as exc: - await handler.get_signals( - GetSignalsRequest(discovery_mode="wholesale", signal_spec="x"), - ToolContext(), - ) - assert exc.value.code == "INVALID_REQUEST" - assert exc.value.field == "discovery_mode" - await asyncio.sleep(0.05) - assert registry._records == {} - assert ran["bg"] is False + # The same mode without a selector is what wholesale discovery looks like. + assert GetSignalsRequest(discovery_mode="wholesale").discovery_mode == "wholesale" # --------------------------------------------------------------------------- @@ -959,7 +960,7 @@ async def test_get_products_external_push_owner_suppresses_sdk_sender(executor) from adcp.types import GetProductsRequest, GetProductsResponse async def _curate(task_ctx): - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") class _Platform(DecisioningPlatform): capabilities = _external_push_capabilities() @@ -991,7 +992,7 @@ async def test_get_signals_external_push_owner_suppresses_sdk_sender(executor) - from adcp.types import GetSignalsRequest, GetSignalsResponse async def _discover(task_ctx): - return GetSignalsResponse(signals=[]) + return GetSignalsResponse(signals=[], cache_scope="public") class _Platform(DecisioningPlatform): capabilities = _external_push_capabilities() @@ -1045,7 +1046,14 @@ def create_media_buy(self, req, ctx): idempotency_key="idem_async_create_1", start_time="2026-05-01T00:00:00Z", end_time="2026-05-31T23:59:59Z", - packages=[{"buyer_ref": "pkg_1", "product_id": "prod_1", "pricing_option_id": "cpm"}], + packages=[ + { + "buyer_ref": "pkg_1", + "product_id": "prod_1", + "pricing_option_id": "cpm", + "budget": 1000.0, + } + ], push_notification_config={ "url": "https://buyer.example.com/wh", "operation_id": "op-cmb-789", @@ -1104,7 +1112,7 @@ async def test_get_products_handoff_no_push_no_webhook(executor) -> None: async def _curate(task_ctx): completed.set() - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") class _Platform(DecisioningPlatform): capabilities = DecisioningCapabilities() diff --git a/tests/test_decisioning_brand_authz_dispatch.py b/tests/test_decisioning_brand_authz_dispatch.py index f159d5751..559ce6b8f 100644 --- a/tests/test_decisioning_brand_authz_dispatch.py +++ b/tests/test_decisioning_brand_authz_dispatch.py @@ -186,7 +186,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): seen.append(ctx.buyer_agent) - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = PlatformHandler( _Platform(), @@ -234,7 +234,7 @@ class _Platform(DecisioningPlatform): accounts = SingletonAccounts(account_id="hello") async def get_products(self, req, ctx): - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = PlatformHandler( _Platform(), @@ -285,7 +285,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): nonlocal method_ran method_ran = True - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = PlatformHandler( _Platform(), @@ -380,7 +380,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): nonlocal method_ran method_ran = True - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = PlatformHandler( _Platform(), @@ -428,7 +428,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): nonlocal method_ran method_ran = True - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = PlatformHandler( _Platform(), @@ -483,7 +483,7 @@ class _Platform(DecisioningPlatform): accounts = SingletonAccounts(account_id="hello") async def get_products(self, req, ctx): - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = PlatformHandler( _Platform(), diff --git a/tests/test_decisioning_buyer_agent_dispatch.py b/tests/test_decisioning_buyer_agent_dispatch.py index 6e5c2d465..76096f326 100644 --- a/tests/test_decisioning_buyer_agent_dispatch.py +++ b/tests/test_decisioning_buyer_agent_dispatch.py @@ -413,7 +413,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): seen.append(ctx.buyer_agent) - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = PlatformHandler( _Platform(), @@ -546,7 +546,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): received_buyer_agent.append(ctx.buyer_agent) - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = _make_handler_with_registry(_Platform(), executor, registry) tool_ctx = ToolContext( @@ -590,7 +590,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): received_agent_url.append(ctx.buyer_agent.agent_url) - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = _make_handler_with_registry(_Platform(), executor, registry) tool_ctx = ToolContext( @@ -840,7 +840,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): received_buyer_agent.append(ctx.buyer_agent) - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = PlatformHandler( _Platform(), @@ -925,7 +925,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): seen.append(ctx.buyer_agent.agent_url) - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = _make_handler_with_registry(_Platform(), executor, registry) diff --git a/tests/test_decisioning_handler.py b/tests/test_decisioning_handler.py index d6d9517ea..65c2e48e7 100644 --- a/tests/test_decisioning_handler.py +++ b/tests/test_decisioning_handler.py @@ -79,7 +79,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): received_account_id.append(ctx.account.id) - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = _make_handler(_Platform(), executor) req = GetProductsRequest(buying_mode="brief", brief="any inventory") @@ -106,7 +106,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): received_principal.append(ctx.account.id) received_auth_info.append(ctx.auth_info) - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = _make_handler(_Platform(), executor) ctx = ToolContext( @@ -203,7 +203,14 @@ async def create_media_buy(self, req, ctx): idempotency_key="idem_aaaa1234567890", start_time="2026-05-01T00:00:00Z", end_time="2026-05-31T23:59:59Z", - packages=[{"buyer_ref": "pkg_1", "product_id": "prod_1", "pricing_option_id": "cpm"}], + packages=[ + { + "buyer_ref": "pkg_1", + "product_id": "prod_1", + "pricing_option_id": "cpm", + "budget": 1000.0, + } + ], ), ToolContext(), ) @@ -237,7 +244,14 @@ async def create_media_buy(self, req, ctx): idempotency_key="idem_aaaa1234567890", start_time="2026-05-01T00:00:00Z", end_time="2026-05-31T23:59:59Z", - packages=[{"buyer_ref": "pkg_1", "product_id": "prod_1", "pricing_option_id": "cpm"}], + packages=[ + { + "buyer_ref": "pkg_1", + "product_id": "prod_1", + "pricing_option_id": "cpm", + "budget": 1000.0, + } + ], ), ToolContext(), ) @@ -367,7 +381,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): received_id.append(ctx.account.id) - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = _make_handler(_Platform(), executor) await handler.get_products(GetProductsRequest(buying_mode="brief", brief="any"), ToolContext()) @@ -389,7 +403,7 @@ class _Platform(DecisioningPlatform): async def get_products(self, req, ctx): received_kind.append(ctx.auth_info.kind if ctx.auth_info else "none") - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") handler = _make_handler(_Platform(), executor) ctx = ToolContext( diff --git a/tests/test_decisioning_property_list.py b/tests/test_decisioning_property_list.py index 090768632..11457c61b 100644 --- a/tests/test_decisioning_property_list.py +++ b/tests/test_decisioning_property_list.py @@ -448,7 +448,12 @@ async def test_filter_applied_and_flag_set(self, response_shape: str) -> None: } ], } - payload = {"products": [p_pass, p_fail], "seller_extra": {"retained": True}} + payload = { + "products": [p_pass, p_fail], + "seller_extra": {"retained": True}, + # Required on every 3.1+ get_products response. + "cache_scope": "public", + } response = ( GetProductsResponse.model_validate(payload) if response_shape == "model" else payload ) @@ -545,7 +550,12 @@ async def test_platform_filter_mode_leaves_response_and_fetcher_untouched( ) -> None: from adcp.types import GetProductsResponse - payload = {"products": [], "property_list_applied": applied, "seller_extra": "keep"} + payload = { + "products": [], + "property_list_applied": applied, + "seller_extra": "keep", + "cache_scope": "public", + } response = GetProductsResponse.model_validate(payload) if response_shape == "model" else payload fetcher = AsyncMock(spec=PropertyListFetcher) result = await maybe_apply_property_list_filter( diff --git a/tests/test_decisioning_refine.py b/tests/test_decisioning_refine.py index 46bfece43..2191b6381 100644 --- a/tests/test_decisioning_refine.py +++ b/tests/test_decisioning_refine.py @@ -242,7 +242,7 @@ class _PlatformWithRefine(DecisioningPlatform): def get_products(self, req, ctx): from adcp.types import GetProductsResponse - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") def refine_get_products(self, req, ctx): return RefineResult( @@ -259,7 +259,7 @@ class _PlatformNoRefine(DecisioningPlatform): def get_products(self, req, ctx): from adcp.types import GetProductsResponse - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") def test_has_refine_support_true_when_method_present() -> None: @@ -320,7 +320,7 @@ async def test_handler_brief_mode_unaffected(executor) -> None: class _P(_PlatformWithRefine): def get_products(self, req, ctx): - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") def refine_get_products(self, req, ctx): # not called raise AssertionError("refine_get_products should not be called for brief mode") diff --git a/tests/test_decisioning_serve.py b/tests/test_decisioning_serve.py index cfbd6bbf5..4ebcaeb4b 100644 --- a/tests/test_decisioning_serve.py +++ b/tests/test_decisioning_serve.py @@ -726,7 +726,12 @@ class _PropertyListPlatform(_SalesPlatformWithRequiredMethods): def get_products(self, req, ctx): assert req.property_list.list_id == "approved" - payload = {"products": [], "property_list_applied": True, "seller_extra": "retained"} + payload = { + "products": [], + "property_list_applied": True, + "seller_extra": "retained", + "cache_scope": "public", + } return ( GetProductsResponse.model_validate(payload) if response_shape == "model" diff --git a/tests/test_decisioning_types.py b/tests/test_decisioning_types.py index a24a69739..1b81e05d3 100644 --- a/tests/test_decisioning_types.py +++ b/tests/test_decisioning_types.py @@ -106,12 +106,36 @@ def test_adcp_error_wire_projection() -> None: def test_adcp_error_minimum_fields() -> None: - """Code-only error projects to the minimum envelope. ``recovery`` - defaults to ``'terminal'`` (do-not-retry).""" + """Code-only error projects to the minimum envelope, with ``recovery`` DERIVED. + + An unstated ``recovery`` is the classification ``enums/error-code.json`` + registers for the code, so ``INVALID_REQUEST`` projects ``correctable``. + This test previously asserted ``terminal`` and its docstring said + ``recovery`` "defaults to 'terminal' (do-not-retry)" — that was the defect, + not the contract: the constructor hardcoded ``terminal`` while 101 of the + 120 registered codes are ``correctable``, so every site that stated nothing + told a buyer to stop retrying something it could fix (salesagent-8qela.47). + """ err = AdcpError("INVALID_REQUEST") assert err.to_wire() == { "code": "INVALID_REQUEST", "message": "INVALID_REQUEST", + "recovery": "correctable", + } + + +def test_adcp_error_minimum_fields_for_an_unregistered_code() -> None: + """An unregistered code has no classification to derive, so it stays terminal. + + The companion to the test above: ``terminal`` is still the answer where the + schema registers nothing, which is what keeps ``INTERNAL_ERROR`` — raised at + 55 sites and absent from both the spec enum and the SDK extensions — + projecting exactly what it did before. + """ + err = AdcpError("INTERNAL_ERROR") + assert err.to_wire() == { + "code": "INTERNAL_ERROR", + "message": "INTERNAL_ERROR", "recovery": "terminal", } diff --git a/tests/test_decisioning_webhook_emit.py b/tests/test_decisioning_webhook_emit.py index bfbaa1ba3..121730265 100644 --- a/tests/test_decisioning_webhook_emit.py +++ b/tests/test_decisioning_webhook_emit.py @@ -494,8 +494,24 @@ def _make_request(*, with_url: bool = True, idem_suffix: str = "x") -> CreateMed "idempotency_key": f"idem_aaaa12345678{idem_suffix}", "start_time": "2026-05-01T00:00:00Z", "end_time": "2026-05-31T23:59:59Z", - # create-media-buy-request.json requires packages, a budget, or a proposal. - "packages": [{"buyer_ref": "pkg_1", "product_id": "prod_1", "pricing_option_id": "cpm"}], + # create-media-buy-request.json's three arms are: explicit packages with + # fixed allocation, where EVERY package carries a budget; explicit + # packages with seller-optimized allocation, which also requires + # total_budget and budget_allocation; or a committed proposal. The + # fixed-allocation arm is the smallest of the three, and the package + # budget is part of it rather than an alternative to it. + # + # This comment used to read "requires packages, a budget, or a + # proposal", which is what the incomplete validator enforced -- the + # defect taught its own contract to whoever wrote the fixture. + "packages": [ + { + "buyer_ref": "pkg_1", + "product_id": "prod_1", + "pricing_option_id": "cpm", + "budget": 1000.0, + } + ], } if with_url: payload["push_notification_config"] = { diff --git a/tests/test_decisioning_wire_dispatch.py b/tests/test_decisioning_wire_dispatch.py index 952f97645..9104674e3 100644 --- a/tests/test_decisioning_wire_dispatch.py +++ b/tests/test_decisioning_wire_dispatch.py @@ -88,15 +88,14 @@ def test_resolver_returns_typed_request_class_not_none(tool_name: str) -> None: "dispatcher will fall back to dict path and shim will crash on " "params.account" ) - assert issubclass(resolved, BaseModel), ( - f"{tool_name}: resolver returned {resolved!r} which is not a " "Pydantic BaseModel subclass" - ) + assert issubclass( + resolved, BaseModel + ), f"{tool_name}: resolver returned {resolved!r} which is not a Pydantic BaseModel subclass" # Generated union arms may carry a numeric suffix (for example # ``CheckGovernanceRequest3``); they are still the typed request arm. - assert "Request" in resolved.__name__, ( - f"{tool_name}: resolved class {resolved.__name__!r} doesn't look " - "like a wire Request type" - ) + assert ( + "Request" in resolved.__name__ + ), f"{tool_name}: resolved class {resolved.__name__!r} doesn't look like a wire Request type" # ---- End-to-end wire path ---- @@ -202,7 +201,16 @@ def _capture(req: Any, ctx: Any) -> Any: handler._platform.build_creative_legacy = _capture # type: ignore[method-assign] result = await caller( - {"brief": "synthesize a 30s spot", "idempotency_key": "emma-test-build-creative-001"} + { + "brief": "synthesize a 30s spot", + "idempotency_key": "emma-test-build-creative-001", + # media-buy/build-creative-request.json: every non-refinement build + # selects exactly one output route, so a brief alone is not a request. + "target_format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_static", + }, + } ) assert "creative_manifest" in result # Same regression guard as get_products — the platform must see a diff --git a/tests/test_delivery_manifest_readback.py b/tests/test_delivery_manifest_readback.py index 6a3c2625e..37453c383 100644 --- a/tests/test_delivery_manifest_readback.py +++ b/tests/test_delivery_manifest_readback.py @@ -106,8 +106,10 @@ def test_delivery_manifest_cannot_bypass_strict_input(model, input_model, reuse_ * an instance is accepted or refused by ordinary pydantic class rules, not by anything about the vocabulary — the canonical response nests a subclass of the generated manifest, so the instance revalidates and - passes; the legacy response nests a ``_forward_compat`` clone that - inherits from nothing, so it is refused as ``model_type``. + passes; the legacy response nests a read-back clone -- ``READBACK_CLONES`` + in ``scripts/post_generate_fixes.py``, built by ``adcp.types._readback`` + and emitted beside the class it copies -- which inherits from nothing, so + it is refused as ``model_type``. Deciding which kinds to accept is the caller's, through ``adcp.types.is_canonical_format_kind``, and @@ -242,13 +244,16 @@ def test_delivery_keeps_optional_manifest_and_kind_defaults(model): payload = delivery_payload() variants = payload["creatives"][0]["variants"] variants[0].pop("manifest") - # Was ``variants[1]["manifest"].pop("format_kind")``. Dropping the key leaves a - # manifest with neither ``format_id`` nor ``format_kind``, which - # core/creative-manifest.json's root oneOf forbids and #1368's - # enforce_root_required_groups now holds at runtime. An explicit null is the - # schema-valid way to state the same thing: the group is keyed on the field being - # present, not on it being non-null. - variants[1]["manifest"]["format_kind"] = None response = model.model_validate(payload) assert response.creatives[0].variants[0].manifest is None - assert response.creatives[0].variants[1].manifest.format_kind is None + + # The kind's optionality is read off the declaration. It used to be shown by + # setting ``format_kind`` to ``None`` on the manifest that IS present, under + # the claim that "the group is keyed on the field being present, not on it + # being non-null" -- which the bundled schema refuses twice over: + # ``format_kind`` is an enum of strings, so a null is not a value it takes, + # and the client drops a null-valued key, so the document SENT names neither + # arm of ``core/creative-manifest.json``'s root ``oneOf``. + assert response.creatives[0].variants[1].manifest.format_kind == FUTURE_KIND + assert not CreativeManifest.model_fields["format_kind"].is_required() + assert CreativeManifest.model_fields["format_kind"].default is None diff --git a/tests/test_dispatch_instance_identity.py b/tests/test_dispatch_instance_identity.py new file mode 100644 index 000000000..5c1859913 --- /dev/null +++ b/tests/test_dispatch_instance_identity.py @@ -0,0 +1,236 @@ +"""What the dispatch hook does with an already-CONSTRUCTED instance. + +The deleted ``ResponseArmDispatchMixin.model_validate`` opened with +``if isinstance(obj, cls): return obj`` -- a pass-through for an instance. The +hook that replaced it installs the branches' union as the class's CORE SCHEMA, +so pydantic decides that, from ``revalidate_instances``. + +THE TWO DIFFER ON MORE THAN ONE INPUT, which this file originally denied. It +claimed they differ on exactly one -- an instance of the LOOSE BASE -- and that +they agree on a branch instance, a sibling branch's instance, a refinement +subclass and a nested field. The second half is false, and the assertion that +stated it has been deleted rather than re-grounded (salesagent-8qela.101). + +Measured over the 19 roots the hook dispatches, handing each a +``model_construct``'d instance: 49 of the 57 direct inputs come back by identity +and 8 are refused. The 8 are three roots -- ``ListAccountChangesResponse``, +``Catchment``, ``FrequencyCapImpressionConstraints`` -- and they are exactly the +roots whose branches carry a compiled ``_require_schema_required_group`` that a +BLANK instance violates. + +That is pydantic's behaviour and not this package's. ``revalidate_instances`` +governs re-validation of a model's FIELDS; it does not suppress a ``mode="after"`` +MODEL validator, which pydantic compiles as a ``function-after`` wrapping the +model schema from the OUTSIDE, where the isinstance short-circuit cannot reach it. +Shown on plain pydantic models with no adcp code involved: a model with such a +validator refuses a ``model_construct``'d instance that violates it -- directly, +through a union, and nested -- while a model WITHOUT one returns it by identity. + +So the override returned objects that violate their own schema and the hook +refuses them. Refusing is the better of the two, there is nothing to restore, and +the pass-through is a property of models carrying no model-level after-validator +rather than of the default this file pins. + +It grades three things, and the SECOND is the durable one: + +* an instance of the loose base is refused by its own name, over every root the + hook dispatches; +* ``revalidate_instances`` is pydantic's default on every generated class; +* every operation that REBUILDS an instance without a document still works, now + that the base dispatches construction (salesagent-setka). ``__new__`` has to + return a blank instance when it is called with no data, and five operations + call it that way. +""" + +from __future__ import annotations + +import copy +import pickle + +import pytest +from pydantic import BaseModel, ValidationError + +# Importing the consolidated module is what loads every generated domain module, +# and the canonical layer is a separate import. The derivation walks what is +# loaded, so a population derived before these imports is empty. +import adcp.types._generated # noqa: F401 +import adcp.types.canonical_creative # noqa: F401 +from adcp.types.base import AdCPBaseModel +from tests.test_branch_dispatch_on_every_path import ( + _accepted_document, + _roots_with_branches, +) + +#: Pydantic's default, and the fact the four agreeing cases rest on. Named here +#: so the assertion reads as a claim about this codebase rather than a repeat of +#: the library's documentation: nothing in ``adcp`` sets this, so nothing in +#: ``adcp`` currently prevents it from changing. +PYDANTIC_DEFAULT_REVALIDATION = "never" + + +def _generated_models() -> list[type[BaseModel]]: + """Every generated model class, by (module, qualname) identity. + + Neither half of that identity is sufficient alone. An exported name hides + aliases -- ``adcp.types.CreativeAsset`` is the canonical refinement while + ``adcp.types._generated.CreativeAsset`` is the base over branches -- and + ``__qualname__`` alone collapses two distinct classes that share a name: + ``CreateMediaBuyRequest`` names one base in the domain tree and another in + the canonical layer, and counting by qualname loses one of them. + """ + import sys + + found: dict[tuple[str, str], type[BaseModel]] = {} + for module in list(sys.modules.values()): + if module is None or not getattr(module, "__name__", "").startswith("adcp.types"): + continue + for value in vars(module).values(): + if isinstance(value, type) and issubclass(value, BaseModel): + found[(value.__module__, value.__qualname__)] = value + return list(found.values()) + + +def test_an_instance_of_the_loose_base_is_refused_by_its_own_name() -> None: + """The one input on which the hook and the deleted pass-through disagree. + + The override returned this object: a branch and a refinement both subclass + the base, so ``isinstance(obj, cls)`` was true for them AND for an instance + of the base itself. The hook refuses it, because the base is not a member of + its own branches' union -- the union is what its core schema now is. + + The refusal is ``model_type`` and nothing else. It does NOT name the root's + combinator, which the document path does name and which + ``core/error.json`` requires of an ``issues[]`` keyword; that gap is + salesagent-jrec5 and is deliberately not asserted here, so this test pins + what is true today rather than failing for a second reason. + """ + roots = _roots_with_branches() + assert roots, "the derivation selected no root to grade" + + for name, root in sorted(roots.items()): + instance = root.model_construct() + with pytest.raises(ValidationError) as caught: + root.model_validate(instance) + assert {entry["type"] for entry in caught.value.errors()} == {"model_type"}, ( + f"{name} refused an instance of itself with " + f"{sorted({e['type'] for e in caught.value.errors()})}, not {{'model_type'}}" + ) + + +def test_the_identity_pass_through_rests_on_a_pydantic_default_nothing_here_states() -> None: + """Pin ``revalidate_instances``, because the hook's treatment of an instance + turns on it. + + Not one generated class sets it, and ``AdCPBaseModel`` does not either, so + there is nothing to inherit: the value is pydantic's ``'never'``. Were a + config change to make it ``'always'`` or ``'subclass-instances'``, the hook + would revalidate objects the deleted override passed through -- which is a + behaviour change inside a config edit, the same shape as the one this file + was written for. + + WHAT IT DOES NOT GOVERN, because this docstring used to claim it did: + ``'never'`` suppresses re-validation of a model's FIELDS and does NOT + suppress a ``mode="after"`` MODEL validator. So it is not what decides + whether a ``model_construct``'d branch instance comes back by identity -- + whether the branch carries a compiled root conjunct is. See the module + docstring and salesagent-8qela.101. + + The population here is every generated model rather than the nineteen bases, + because setting ``revalidate_instances`` on a BASE is the half that changes + nothing: the base's core schema IS the branches' union and it is the union + MEMBERS that accept an instance. Scoping it to the bases would pin the + classes that do not decide the behaviour. + """ + models = _generated_models() + assert len(models) > 1000, f"the derivation found only {len(models)} generated models" + + setters = [ + f"{model.__module__}.{model.__qualname__}" + for model in models + if "revalidate_instances" in model.model_config + ] + assert not setters, ( + f"{len(setters)} generated class(es) set revalidate_instances, so the identity " + f"pass-through this file asserts is no longer uniform: {sorted(setters)[:5]}" + ) + assert "revalidate_instances" not in AdCPBaseModel.model_config, ( + "AdCPBaseModel now sets revalidate_instances, so the generated tree inherits a " + "revalidation policy and the identity assertions above need re-measuring" + ) + assert ( + BaseModel.model_config.get("revalidate_instances", PYDANTIC_DEFAULT_REVALIDATION) + == PYDANTIC_DEFAULT_REVALIDATION + ), "pydantic's own default for revalidate_instances moved" + + +def test_rebuilding_an_instance_without_a_document_still_works() -> None: + """``__new__`` dispatches construction, so a no-data call must not validate. + + ``BranchDispatchMixin.__new__`` chooses a branch from the payload + (salesagent-setka). Five operations rebuild an instance with NO payload and + reach that same hook, and all five would raise a ``ValidationError`` for the + missing fields of an empty document if it tried to dispatch them. + + Measured on the BASE, which is the half that matters and the half a careless + probe misses: the same five on a BRANCH instance never reach the hook at all, + because a branch does not declare ``_branch_models`` and the guard's second + condition short-circuits. A probe that built its instance by constructing a + valid document got a branch back and reported all five unaffected -- true of + that probe, and nothing about the guard. + """ + roots = _roots_with_branches() + assert roots, "the derivation selected no root to grade" + + for name, root in sorted(roots.items()): + blank = root.model_construct() + assert type(blank) is root, ( + f"{name}.model_construct() returned {type(blank).__name__}; a no-data " + "rebuild must not be dispatched to a branch" + ) + for label, rebuild in ( + ("copy.copy", lambda b=blank: copy.copy(b)), + ("copy.deepcopy", lambda b=blank: copy.deepcopy(b)), + ("pickle round trip", lambda b=blank: pickle.loads(pickle.dumps(b))), + ("model_copy", lambda b=blank: b.model_copy()), + ): + rebuilt = rebuild() + assert ( + type(rebuilt) is root + ), f"{name}: {label} returned {type(rebuilt).__name__}, not the class it copied" + + +def test_an_adopter_subclass_constructs_as_itself() -> None: + """A subclass that does not declare branches is not dispatched away from. + + ``LegacyCreativeAsset`` is a deliberately exposed public alias OF A BASE, and + an adopter subclassing one of these roots and calling ``super().__init__`` + must get an instance of THEIR class -- not of a branch, which would discard + whatever they added. The gate is "declares ``_branch_models``", not + ``issubclass``, and this is the input that separates the two: a plain + subclass is a subclass of the base and must still construct as itself. + """ + roots = _roots_with_branches() + assert roots, "the derivation selected no root to grade" + + graded = 0 + for name, root in sorted(roots.items()): + document = _accepted_document(root) + if document is None: + continue + + class Adopter(root): # type: ignore[misc,valid-type] + """An adopter's own subclass, with its own __init__.""" + + def __init__(self, **data: object) -> None: + super().__init__(**data) + + constructed = Adopter(**document) + assert type(constructed) is Adopter, ( + f"{name}: an adopter subclass constructed as {type(constructed).__name__}, " + "so super().__init__ dispatched away from the caller's class" + ) + # And the object is usable, which is the whole point of the fix. + constructed.model_dump(mode="json") + graded += 1 + + assert graded >= 18, f"only {graded} roots had a document to construct from" diff --git a/tests/test_domain_facades_are_lazy.py b/tests/test_domain_facades_are_lazy.py new file mode 100644 index 000000000..a1bcdf000 --- /dev/null +++ b/tests/test_domain_facades_are_lazy.py @@ -0,0 +1,81 @@ +"""Every generated domain facade binds its names lazily. + +A domain facade (``adcp/types/domains/<domain>/__init__.py``, written by +``scripts/consolidate_exports.py``) re-exports the names its domain declares +exactly once. When it bound them EAGERLY, importing any submodule of the domain +ran the facade and therefore imported every leaf -- so one leaf's dependencies +became the whole domain's. ``core`` and ``formats.canonical`` reference each +other's schemas, which is a benign submodule-level cycle that Python resolves by +import order; the eager facade amplified it into a package-level one, and all 16 +modules under ``formats/canonical/`` raised ``ImportError`` on a direct import, +as did ``content_standards`` entire, ``core.product``, +``core.product_allowed_action`` and four top-level modules. + +This guard is STRUCTURAL: it reads the facades and refuses an eager import of a +domain's own submodules. The behavioural half -- that a leaf of every package +still imports first in a fresh interpreter -- belongs to +``test_submodule_imports_standalone`` in ``tests/test_lazy_types.py``, which +already owned that check for the top-level modules and now derives the +``adcp.types`` tree as well. Two checks, not two populations of one check: this +one is exact about the property the fix establishes and no choice of population +can dodge it, while that one catches an amplifier appearing somewhere this AST +rule does not look. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +_TYPES = Path(__file__).resolve().parent.parent / "src" / "adcp" / "types" +_DOMAINS = _TYPES / "domains" + + +def _module_name(path: Path) -> str: + parts = path.resolve().relative_to(_TYPES.parent.parent).with_suffix("").parts + return ".".join(parts).removesuffix(".__init__") + + +def _facades() -> list[Path]: + found = sorted(path for path in _DOMAINS.glob("*/__init__.py")) + assert ( + len(found) > 10 + ), f"found {len(found)} domain facades, so this guard grades almost nothing" + return found + + +def _eager_submodule_imports(path: Path) -> list[str]: + """Imports of this domain's own submodules that run at import time. + + An import nested under ``if TYPE_CHECKING:`` does not run, which is where a + facade keeps the surface type checkers need. Anything at module level does. + """ + tree = ast.parse(path.read_text()) + domain = _module_name(path) + eager: list[str] = [] + for node in tree.body: + if isinstance(node, ast.ImportFrom) and (node.module or "").startswith(f"{domain}."): + eager.append(f"line {node.lineno}: from {node.module} import ...") + elif isinstance(node, ast.Import): + eager.extend( + f"line {node.lineno}: import {alias.name}" + for alias in node.names + if alias.name.startswith(f"{domain}.") + ) + return eager + + +@pytest.mark.parametrize("facade", _facades(), ids=_module_name) +def test_every_domain_facade_is_lazy(facade: Path) -> None: + """No facade may bind its own submodules at import time. + + The one that did made every leaf of its domain depend on every other leaf, + and 38 modules could not be imported on their own because of it. + """ + eager = _eager_submodule_imports(facade) + assert not eager, ( + f"{_module_name(facade)} imports its own submodules eagerly, which makes every " + f"leaf of the domain depend on every other leaf:\n " + "\n ".join(eager) + ) diff --git a/tests/test_dropped_keyword_doors.py b/tests/test_dropped_keyword_doors.py new file mode 100644 index 000000000..5daa7abee --- /dev/null +++ b/tests/test_dropped_keyword_doors.py @@ -0,0 +1,260 @@ +"""A keyword the grammar compiles must reach the grammar. + +``scripts/schema_constraints.py`` has a production for 36 JSON Schema keywords. +A production is worth nothing if the keyword never gets handed to it, and twice +now it has not been: the ``allOf`` gate closed by a2989c205, and the +whole-object keywords of salesagent-09ghh. Both were a compiler with a shut +door, and in both cases the content was never EXAMINED rather than examined and +skipped -- which is why nothing failed. + +09ghh had FOUR doors, each independently sufficient to lose the constraint, and +the first three were the ones filed: + +1. the gate -- ``_states_logic`` did not admit a root carrying one, so the root + was reported as stating nothing; +2. the reader -- ``_Parser.read`` looked at the combinator, ``if``, + ``dependencies``, ``dependentRequired``, ``not`` and ``allOf`` members, and a + top-level ``minProperties`` is in none of those positions. Widening the gate + ALONE was measured to produce ten admitted roots and ten EMPTY validators; +3. the restatement rule -- ``_declaration_of`` kept them as "what the generator + renders", so ``_keyword``'s equality test reported them already enforced; +4. the PRIMITIVE -- ``PropCount`` rendered to ``_c.length``, which is ``len()``, + which raises ``TypeError`` on a pydantic object and so returned ``True`` for + every object. With doors 1-3 open and this one shut, ten validators were + emitted and ``minProperties``/``maxProperties`` still graded nothing. That + door was not on the bead; it was found by probing the fix. + +One live assertion per door, so a regression in any one of them fails here. +""" + +from __future__ import annotations + +import pytest + +import adcp.types._conditions as _c + +# ``from scripts.x import y``, like the other 25 test files that reach into +# scripts/, and NOT a ``sys.path`` insert onto scripts/ with a top-level import. +# ``scripts/compile_conditions.py`` and ``scripts/branch_classes.py`` both PREFER +# the top-level spelling and fall back to the package one, so putting scripts/ on +# ``sys.path`` makes BOTH spellings importable -- and then ``compile_condition`` +# tests ``isinstance`` against ``schema_constraints.AllOf`` while a caller holds +# ``scripts.schema_constraints.AllOf``. Two classes, same name, so the check is +# False and the emitter reports a node it cannot render. Measured: with this file +# collected, ``AllOf`` had 4 bindings over 2 distinct class objects, and +# test_branch_classes and test_root_constraint_emission failed for that reason +# alone. +from scripts.compile_conditions import compile_condition +from scripts.schema_constraints import ( + COMPILED_KEYWORDS, + DROPPED_KEYWORDS, + LOGIC_KEYS, + OPAQUE, + PropCount, + _declaration_of, + read_root_constraints, + root_carries_constraints, +) + +#: The keywords ``datamodel-code-generator`` RENDERS, as a field constraint or as +#: the field itself. The gate's silence costs nothing for these: the generated +#: class already carries them, so a root restating one is restating something +#: true of every instance. +#: +#: Named rather than derived because the evidence is a spelling, not a fact about +#: this module -- ``minLength`` becomes ``min_length=``, ``minimum`` becomes +#: ``ge=``, ``type`` becomes the annotation itself -- and a derivation over those +#: spellings would be a second, looser statement of what codegen does. What IS +#: derived is the partition: this set plus the two dropped sets must be exactly +#: ``COMPILED_KEYWORDS``, so a production added to the grammar has to be +#: classified before this file passes. +RENDERED_BY_CODEGEN = frozenset( + { + "$ref", + "additionalProperties", + "const", + "enum", + "exclusiveMaximum", + "exclusiveMinimum", + "items", + "maxItems", + "maxLength", + "maximum", + "minItems", + "minLength", + "minimum", + "pattern", + "properties", + "required", + "type", + } +) + +#: A value that makes each keyword's node non-trivial, so the gate is asked a +#: real question rather than one about an empty node. +_SAMPLE = { + "allOf": [{"type": "object"}], + "anyOf": [{"type": "object"}], + "oneOf": [{"type": "object"}], + "contains": {"type": "string"}, + "dependencies": {"a": ["b"]}, + "dependentRequired": {"a": ["b"]}, + "dependentSchemas": {"a": {"type": "object"}}, + "else": {"type": "object"}, + "if": {"required": ["a"]}, + "maxProperties": 16, + "minProperties": 1, + "not": {"type": "string"}, + "patternProperties": {"^a": {"type": "string"}}, + "propertyNames": {"maxLength": 8}, + "then": {"type": "object"}, + "uniqueItems": True, +} + + +def test_every_compiled_keyword_is_either_dropped_or_rendered() -> None: + """The partition is total, so a new production must be classified. + + This is the assertion that would have caught both instances of the defect + class before they shipped: a keyword with a production and no home in either + set is a compiler nothing feeds. + """ + classified = LOGIC_KEYS | DROPPED_KEYWORDS | RENDERED_BY_CODEGEN + unclassified = COMPILED_KEYWORDS - classified + assert not unclassified, ( + f"{sorted(unclassified)} have a production in the grammar but are neither " + "dropped by codegen (so the gate must admit them) nor rendered by it (so a " + "root restating them is already enforced). A keyword in neither is a " + "compiler with a shut door." + ) + invented = classified - COMPILED_KEYWORDS - LOGIC_KEYS + assert not invented, f"{sorted(invented)} are classified but the grammar compiles nothing" + + +#: Dropped keywords that cannot stand alone at a root, with the reason. ``then`` +#: and ``else`` assert nothing without the ``if`` that selects them, and a root +#: ``allOf`` is admitted through its MEMBERS -- ``root_carries_constraints`` +#: recurses, so ``{allOf: [{if: ...}]}`` is admitted while an ``allOf`` of +#: declarations correctly is not. Both are checked below instead of here. +_NOT_ALONE = frozenset({"allOf", "then", "else"}) + + +@pytest.mark.parametrize("keyword", sorted((LOGIC_KEYS | DROPPED_KEYWORDS) - _NOT_ALONE)) +def test_door_one_the_gate_admits_every_keyword_codegen_drops(keyword: str) -> None: + """The ADMISSION gate says yes to a root carrying it. + + ``root_carries_constraints``, not ``_states_logic``: the gate is the public + predicate and it recurses into ``allOf`` members, which is the distinction + the first version of this test got wrong by asserting the inner helper. + + Live, not declarative. Before salesagent-09ghh this failed for all six + members of ``DROPPED_KEYWORDS``, and it also failed for + ``dependentSchemas`` -- a keyword with a production, named by + ``LOGIC_KEYS``, that neither the gate nor the reader looked at. No pinned + schema uses it, so that one was a LATENT instance of the same defect, found + by this test rather than by a probe. + """ + sample = _SAMPLE.get(keyword) + assert sample is not None, f"{keyword} has no sample; add one to _SAMPLE" + assert root_carries_constraints({"type": "object", keyword: sample}), ( + f"the gate reports a root carrying {keyword!r} as stating nothing, so its " + "content is never examined" + ) + + +def test_door_one_admits_a_dropped_keyword_inside_an_all_of_member() -> None: + """The position the gate was widened to read, and the one it must not over-read. + + A root ``allOf`` is inheritance for the declarative keys and a carrier of + logic for the dropped ones, so a member carrying either is admitted while a + member carrying only declarations is not. + """ + for keyword in sorted(DROPPED_KEYWORDS | {"if"}): + member = {"type": "object", keyword: _SAMPLE[keyword]} + assert root_carries_constraints({"allOf": [member]}), keyword + assert not root_carries_constraints( + {"allOf": [{"type": "object", "properties": {"a": {"type": "string"}}}]} + ), "an allOf of pure declarations states nothing and must not be admitted" + for keyword in sorted(_NOT_ALONE - {"allOf"}): + assert not root_carries_constraints( + {"type": "object", keyword: _SAMPLE[keyword]} + ), f"{keyword!r} alone asserts nothing -- it needs the 'if' that selects it" + + +@pytest.mark.parametrize("keyword", sorted(DROPPED_KEYWORDS)) +def test_door_three_a_dropped_keyword_is_not_part_of_the_declaration(keyword: str) -> None: + """``_declaration_of`` leaves it out, so the restatement rule cannot claim it. + + The generator renders none of these, so a root stating one is not restating + the class. Keeping them made ``minProperties`` inside an ``allOf`` member + compile against a declaration that had absorbed it. + """ + node = {"type": "object", "properties": {"a": {"type": "string"}}, keyword: _SAMPLE[keyword]} + assert keyword not in _declaration_of(node) + # And through an allOf member, which is the position that made this visible. + assert keyword not in _declaration_of({"allOf": [node]}) + + +def test_door_two_the_reader_compiles_a_root_s_own_whole_object_keyword() -> None: + """A top-level ``minProperties`` becomes a conjunct, not an empty validator. + + The reader used to look only at the combinator, the conditional positions + and ``allOf`` members. Admission without this produced a validator block + with nothing in it, which is indistinguishable from a passing check. + """ + constraints = read_root_constraints( + {"type": "object", "properties": {"a": {"type": "string"}}, "minProperties": 1}, + OPAQUE, + branches_render_as_classes=False, + schema_file="probe/min-properties.json", + ) + assert constraints.conjuncts, "the root was admitted and compiled to no conjunct at all" + assert any( + isinstance(conjunct, PropCount) for conjunct in constraints.conjuncts + ), f"minProperties compiled to {constraints.conjuncts!r}, not to a PropCount" + + +def test_door_four_prop_count_renders_to_the_primitive_that_can_see_an_object() -> None: + """The WIRING, which is the half door 4 actually was. + + The first version of this file graded only the two primitives' behaviour, + and reverting the renderer left all of it green -- the guard tested that + ``_c.members`` works, not that ``minProperties`` reaches it. Found by + reverting each door in turn and watching which tests moved; this one did + not, so it was not yet grading its own door. + """ + rendered = compile_condition(PropCount(minimum=1)) + assert "_c.members(" in rendered, ( + f"minProperties renders to {rendered!r}; _c.length cannot measure a " + "pydantic object, so that is a validator which grades nothing" + ) + assert "_c.length(" not in rendered + assert "_c.members(" in compile_condition(PropCount(maximum=16)) + + +def test_door_four_the_primitive_counts_a_model_s_members() -> None: + """``PropCount`` renders to ``_c.members``, and that grades an object. + + ``_c.length`` cannot: it is ``len()``, a pydantic object has no ``__len__``, + and the ``TypeError`` branch returns True. So the emitted validator passed + every object while looking like a check. Graded on both primitives, because + the defect was routing one keyword to the other. + """ + + class OneMember: + model_fields_set = {"a"} + model_extra = None + + instance = OneMember() + assert _c.members(instance, minimum=1) is True + assert _c.members(instance, minimum=2) is False, ( + "_c.members does not count a model's supplied members, so minProperties " + "grades nothing on an object root" + ) + assert _c.members({f"k{index}": 1 for index in range(22)}, maximum=16) is False + # The primitive this was wrongly routed through still cannot see an object, + # which is why the split was necessary rather than tidy. + assert _c.length(instance, minimum=2) is True + # And it still measures the four sizes it is for. + assert _c.length("ab", minimum=3) is False + assert _c.length([1], minimum=2) is False diff --git a/tests/test_error_field_jsonpath_lite.py b/tests/test_error_field_jsonpath_lite.py new file mode 100644 index 000000000..2c0638038 --- /dev/null +++ b/tests/test_error_field_jsonpath_lite.py @@ -0,0 +1,460 @@ +"""``error.field`` is ``issues[0].pointer`` translated, and one owner does it. + +``core/error.json`` requires it on ``field``, on ``issues`` and on +``issues[].pointer``: ``field`` carries ``packages[0].targeting`` where the +pointer carries ``/packages/0/targeting``. + +Three case populations, none of them derived from the translator: + +* the arrow examples parsed out of the pinned schema bundle — the spec is the + authority for what a translation should produce; +* whatever pointers the jsonschema oracle and pydantic emit on real malformed + payloads, round-tripped; +* hand-authored pointer shapes the spec's examples do not cover. + +The first two are asserted non-empty, since an extraction that matched nothing +yields a parametrize that passes. +""" + +from __future__ import annotations + +import json +import pathlib +import re + +import pytest +from jsonschema import Draft7Validator +from pydantic import ValidationError + +from adcp.types import CreateMediaBuyRequest +from adcp.validation.pydantic_errors import _keyword, issues_from_validation_error +from adcp.validation.schema_errors import build_adcp_validation_error_payload +from adcp.validation.schema_validator import ( + ValidationIssue, + field_to_pointer, + pointer_to_field, + validate_request, +) + +_SCHEMA_ROOT = pathlib.Path(__file__).resolve().parents[1] / "schemas" / "cache" / "3.2" + +#: ``'/packages/0/targeting' -> 'packages[0].targeting'`` as the descriptions +#: write it, in either arrow spelling. +_ARROW = re.compile(r"[`']((?:/[^`' ]+)+)[`']\s*(?:→|->)\s*[`']([^`' ]+)[`']") + + +def _spec_example_pairs() -> list[tuple[str, str]]: + """Every pointer -> JSONPath-lite example the pinned bundle states.""" + strings: list[str] = [] + + def walk(node: object) -> None: + if isinstance(node, dict): + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + elif isinstance(node, str): + strings.append(node) + + for path in sorted(_SCHEMA_ROOT.rglob("*.json")): + try: + walk(json.loads(path.read_text())) + except (OSError, json.JSONDecodeError): # pragma: no cover + continue + return sorted(set(_ARROW.findall("\n".join(strings)))) + + +def _real_validation_pointers() -> list[str]: + """Pointers the two validators emit on real malformed payloads. + + Nested shapes are the point: a translator that only sees one-segment + pointers is untested. + """ + payloads: list[object] = [ + {"packages": [{"targeting_overlay": {"geo_countries": [5]}}]}, + {"packages": "not-a-list"}, + {"buyer_ref": 7, "packages": [{"products": [{}]}]}, + {}, + "not-an-object", + [1, 2], + ] + pointers: set[str] = set() + for payload in payloads: + outcome = validate_request("create_media_buy", payload) + pointers.update(issue.pointer for issue in outcome.issues) + try: + CreateMediaBuyRequest.model_validate(payload) + except ValidationError as error: + pointers.update(issue.pointer for issue in issues_from_validation_error(error)) + return sorted(pointers) + + +#: Hand-authored. The three shapes with no dotted rendering, plus root. +AUTHORED_EDGE_CASES = [ + "/", + "/a.b", + "/a~1b", + "/a~0b", + "/0", + "/x/a.b/y", + "/ext/vendor.key/value", + "/details/a'b", + "/packages/0/optimization_goals/1/event_sources/0", +] + +SPEC_PAIRS = _spec_example_pairs() +REAL_POINTERS = _real_validation_pointers() + + +@pytest.mark.parametrize(("pointer", "expected_field"), SPEC_PAIRS) +def test_spec_stated_examples(pointer, expected_field): + """The translator reproduces every example the pinned spec states.""" + assert pointer_to_field(pointer) == expected_field + + +def test_spec_examples_were_actually_found(): + """An extraction that matched nothing yields a parametrize that passes.""" + assert SPEC_PAIRS, f"no arrow examples extracted from {_SCHEMA_ROOT}" + + +@pytest.mark.parametrize("pointer", REAL_POINTERS) +def test_round_trip_on_real_validation_pointers(pointer): + """Translating a real pointer and back returns the same pointer.""" + assert field_to_pointer(pointer_to_field(pointer)) == pointer + + +def test_real_validation_pointers_are_not_empty_and_are_nested(): + """The validators must actually have produced pointers, including deep ones.""" + assert REAL_POINTERS, "no pointers collected from real validation failures" + assert any(p.count("/") >= 3 for p in REAL_POINTERS), REAL_POINTERS + + +@pytest.mark.parametrize("pointer", AUTHORED_EDGE_CASES) +def test_round_trip_on_authored_edge_cases(pointer): + assert field_to_pointer(pointer_to_field(pointer)) == pointer + + +def test_translation_is_not_the_identity(): + """Every round-trip above would also pass for an identity pair.""" + assert pointer_to_field("/packages/0/targeting") != "/packages/0/targeting" + + +def _issue(pointer: str) -> ValidationIssue: + return ValidationIssue(pointer=pointer, message="m", keyword="type", schema_path="") + + +@pytest.mark.parametrize("pointer", AUTHORED_EDGE_CASES + REAL_POINTERS) +def test_builder_field_is_the_translated_first_pointer(pointer): + """The dual-write the spec demands, asserted on the builder's output. + + ONE RULE FOR EVERY POINTER, the root included. ``core/error.json`` conditions + the dual-write on the presence of ``issues``, not on the shape of the + pointer -- "When `issues` is present, sellers MUST also populate `field` from + `issues[0]` ... MUST (not SHOULD) so consumers reading `field` get + deterministic behavior across sellers" -- and ``field`` carries no + ``minLength``, so the root's empty translation is a legal value. + + This used to branch: a root pointer asserted ``"field" not in payload``, on + the grounds that the root has no field path to name and ``field`` is not in + error.json's ``required`` list. Both are true and neither is the condition + the pin states, so that branch asserted the one thing the MUST forbids and is + gone rather than re-tuned (salesagent-8qela.125). + """ + payload = build_adcp_validation_error_payload("create_media_buy", "request", [_issue(pointer)]) + assert payload["field"] == pointer_to_field(pointer) + assert not payload["field"].startswith("/"), "field is still an RFC 6901 pointer" + + +def test_builder_emits_top_level_issues_and_recovery(): + payload = build_adcp_validation_error_payload( + "create_media_buy", "request", [_issue("/packages/0/targeting")] + ) + assert payload["issues"] == payload["details"]["issues"] + assert payload["recovery"] == "correctable" + + +def test_builder_omits_field_and_issues_when_there_are_none(): + """``issues`` has no ``minItems``, so an empty list is legal. + + With no ``issues[0]`` there is no source for ``field``, and both fields are + optional — so neither is emitted. + """ + payload = build_adcp_validation_error_payload("create_media_buy", "request", []) + assert "field" not in payload + assert "issues" not in payload + assert payload["details"]["issues"] == [] + + +def _handler_request_models() -> list[type]: + """The request models the handler classes validate ``params`` into. + + Derived from the class bodies, so a handler added later is covered. + """ + import inspect + + from adcp import types + from adcp.server.content_standards import ContentStandardsHandler + from adcp.server.governance import GovernanceHandler + from adcp.server.sponsored_intelligence import SponsoredIntelligenceHandler + + found: dict[str, type] = {} + for handler_cls in (ContentStandardsHandler, GovernanceHandler, SponsoredIntelligenceHandler): + for name, member in vars(handler_cls).items(): + if name.startswith(("_", "handle_")) or not inspect.iscoroutinefunction(member): + continue + match = re.search(r"(\w+)\.model_validate\(params\)", inspect.getsource(member)) + if match and (model := getattr(types, match.group(1), None)) is not None: + found[match.group(1)] = model + return list(found.values()) + + +HANDLER_REQUEST_MODELS = _handler_request_models() + + +def test_handler_request_models_were_discovered(): + assert len(HANDLER_REQUEST_MODELS) >= 21, HANDLER_REQUEST_MODELS + + +@pytest.mark.parametrize("model", HANDLER_REQUEST_MODELS, ids=lambda m: m.__name__) +def test_no_reachable_pointer_names_a_pydantic_internal(model): + """A pointer must address the buyer's document, not pydantic's validators. + + pydantic's ``loc`` is not a document path: a model-level ``function-after`` + wrapper or a wrapped union prefixes synthetic elements naming the validator + and the generated branch class, so the pointer no longer resolves against + what the buyer sent and ``field`` inherits it. No model here does that; + ``CreateMediaBuyRequest`` does. Wiring such a model to ``invalid_request`` + must fail here rather than on the wire (salesagent-8qela.28). + """ + for payload in ("x", {}, {"account": "x"}, [1]): + try: + model.model_validate(payload) + except ValidationError as error: + for issue in issues_from_validation_error(error): + assert "function-" not in issue.pointer, issue.pointer + assert "[" not in issue.pointer, issue.pointer + assert pointer_to_field(issue.pointer).count("['") == 0, issue.pointer + + +def _generated_custom_error_types() -> list[str]: + """The error types the generated models raise, read from their source. + + The generator emits ``PydanticCustomError('allOf', ...)`` for every schema + conditional, so these names come from the generated tree rather than from + a list kept here. + """ + import ast + + found: set[str] = set() + for path in (pathlib.Path(__file__).resolve().parents[1] / "src/adcp/types").rglob("*.py"): + try: + tree = ast.parse(path.read_text()) + except SyntaxError: # pragma: no cover + continue + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + name = getattr(node.func, "id", getattr(node.func, "attr", "")) + if name != "PydanticCustomError" or not node.args: + continue + first = node.args[0] + if isinstance(first, ast.Constant) and isinstance(first.value, str): + found.add(first.value) + return sorted(found) + + +#: Of the types the generator raises, the ones that ARE JSON Schema keywords. +#: Filtered rather than skipped at runtime, so the parametrize carries only +#: applicable cases and its emptiness is an assertable fact. +GENERATED_KEYWORD_TYPES = [ + t for t in _generated_custom_error_types() if t in Draft7Validator.VALIDATORS +] + + +def test_generated_keyword_types_were_found(): + """Population check: an empty scan would make the test below vacuous.""" + assert GENERATED_KEYWORD_TYPES, "no JSON Schema keyword types found under src/adcp/types" + + +@pytest.mark.parametrize("error_type", GENERATED_KEYWORD_TYPES) +def test_a_keyword_the_generator_raises_survives_the_projection(error_type): + """A pydantic type that IS a JSON Schema keyword must not be flattened. + + ``core/error.json`` asks ``issues[].keyword`` for a name "drawn from the + JSON Schema vocabulary … do not invent custom values here". The generated + models raise exactly such names, so mapping them to a fallback throws away + the field's whole purpose — and ``safe_message_for`` already renders them. + """ + assert _keyword(error_type) == error_type + + +def test_both_lanes_emit_the_same_envelope(): + """The jsonschema oracle and the pydantic projection must not disagree. + + Two routes reach ``VALIDATION_ERROR`` — the oracle through + ``validate_request``, a handler's pydantic rejection through + ``invalid_request`` — and both must produce one envelope. + + RED, and ledgered as salesagent-8qela.93. Ordering is not what it is + waiting for: the oracle names ``/idempotency_key`` and four more located + failures here, while the pydantic lane emits ONE entry at the document root, + so there is no located entry for any rule to promote. That is the branch + dispatch electing the branch whose ``mode="before"`` validator fired, and it + is fixed in ``_conditions.py``. + + ``core/error.json`` requires ``field`` whenever ``issues`` is present, so + the lane omitting it is non-conformant rather than exercising a legitimate + root-level case — do not satisfy this by choosing a payload that keeps both + lanes field-level, which would retire the only check that sees the defect. + """ + oracle = build_adcp_validation_error_payload( + "create_media_buy", "request", validate_request("create_media_buy", {"packages": 1}).issues + ) + try: + CreateMediaBuyRequest.model_validate({"packages": 1}) + raise AssertionError("payload validated when the test needs it rejected") + except ValidationError as error: + handler = build_adcp_validation_error_payload( + "create_media_buy", "request", issues_from_validation_error(error) + ) + + assert sorted(oracle) == sorted(handler), (sorted(oracle), sorted(handler)) + for payload in (oracle, handler): + assert payload["recovery"] == "correctable" + assert payload["issues"] == payload["details"]["issues"] + assert payload["field"] == pointer_to_field(payload["issues"][0]["pointer"]) + assert not payload["field"].startswith("/") + + # THE SUBSTANTIVE AGREEMENT, and the assertion this test exists for. The two + # above are about the envelope's SHAPE, and both lanes satisfy them once + # ``field`` is dual-written unconditionally (salesagent-8qela.125) -- while + # the lanes still disagree about WHERE the document is wrong: the oracle + # names ``/idempotency_key`` and four more located failures, the pydantic + # lane one entry at the document root. + # + # DO NOT DELETE THIS TO REACH GREEN. It is the only check that sees + # salesagent-8qela.116 -- a root-required field's absence read as + # branch-selecting requiredness, so no branch is ever reached and the refusal + # collapses to the combinator with nothing located to promote. Closing .116 + # needs the generator to emit the root's required set; until then this is RED + # for a named, filed cause, which is the honest state. The docstring's + # warning against satisfying this test by keeping both lanes field-level + # applies to removing this assertion too. + for lane, payload in (("oracle", oracle), ("pydantic", handler)): + assert payload["field"], ( + f"the {lane} lane located no failure: issues[0] is at the document " + f"root, so `field` is the empty string and a pre-3.1 consumer reading " + f"it alone learns nothing — {payload['issues']}" + ) + + +def test_version_unsupported_field_comes_from_the_translator(): + """VERSION_UNSUPPORTED gets its ``field`` from the translator too.""" + issue = ValidationIssue( + pointer="/adcp_version", + message="no bundled request validator", + keyword="schema_unavailable", + schema_path="", + ) + payload = build_adcp_validation_error_payload("create_media_buy", "request", [issue]) + assert payload["code"] == "VERSION_UNSUPPORTED" + assert payload["field"] == "adcp_version" + + +#: Payloads reaching ``create_media_buy``, the one request root whose schema +#: carries a root ``anyOf``. "Packages only" is the realistic case: a buyer +#: omits each package's ``budget`` (arm 0 requires it) and sends a bad +#: ``geo_countries`` element, so the pin refuses the document at the ROOT and +#: at a field at once. +ROOT_AND_FIELD_PAYLOADS = [ + { + "adcp_version": "3.2", + "idempotency_key": "11111111-2222-3333-4444-555555555555", + "account": {"account_id": "acct_1"}, + "brand": {"domain": "example.com"}, + "start_time": "2026-02-01T00:00:00Z", + "end_time": "2026-02-28T00:00:00Z", + "packages": [ + { + "buyer_ref": "pkg_1", + "product_id": "p1", + "pricing_option_id": "po_1", + "targeting_overlay": {"geo_countries": [5]}, + } + ], + }, + {"packages": [{"targeting_overlay": {"geo_countries": [5]}}]}, + {"buyer_ref": 7, "packages": [{"products": [{}]}]}, + {}, +] + + +def _envelopes_with_both_kinds_of_issue() -> list[tuple[str, dict[str, object]]]: + """Envelopes whose issue set holds a root entry AND a located one. + + Derived by running both lanes over the payloads above rather than authored, + so the cases are whatever the validators really produce. An entry is + located when its pointer translates to a JSONPath-lite field path; the + document root translates to the empty string, which is not one. + """ + found: list[tuple[str, dict[str, object]]] = [] + for index, payload in enumerate(ROOT_AND_FIELD_PAYLOADS): + lanes: list[tuple[str, list[ValidationIssue]]] = [ + ("oracle", list(validate_request("create_media_buy", payload).issues)) + ] + try: + CreateMediaBuyRequest.model_validate(payload) + except ValidationError as error: + lanes.append(("pydantic", issues_from_validation_error(error))) + for lane, issues in lanes: + if not issues: + continue + located = [i for i in issues if pointer_to_field(i.pointer)] + rooted = [i for i in issues if not pointer_to_field(i.pointer)] + if located and rooted: + found.append( + ( + f"{lane}-{index}", + build_adcp_validation_error_payload("create_media_buy", "request", issues), + ) + ) + return found + + +ROOT_AND_FIELD_ENVELOPES = _envelopes_with_both_kinds_of_issue() + + +def test_a_case_with_both_kinds_of_issue_exists(): + """Without one, the test below asserts nothing: every set would be located.""" + assert ROOT_AND_FIELD_ENVELOPES, ( + "no payload produced an issue set holding BOTH a root entry and a located " + "one, so the ordering rule is ungraded" + ) + + +@pytest.mark.parametrize( + ("label", "payload"), ROOT_AND_FIELD_ENVELOPES, ids=[c[0] for c in ROOT_AND_FIELD_ENVELOPES] +) +def test_field_comes_from_a_located_issue_whenever_one_exists(label, payload): + """``issues[0]`` names a field in the buyer's document, never the root. + + ``core/error.json`` requires ``field`` to be ``issues[0].pointer`` + translated, and types it as a "Field path … in JSONPath-lite format (e.g. + 'packages[0].targeting')". The document root has no such spelling, so an + entry pointing at the root cannot be ``issues[0]`` without leaving ``field`` + unpopulated — and ``field`` is graded, not advisory: + ``compliance/3.2.1/domains/media-buy/scenarios/refine_finalize_exclusivity.yaml`` + checks ``field_present`` on ``adcp_error.field``. + + A root entry may still APPEAR in ``issues[]``: the pin's own envelope in + ``protocol/calling-an-agent.mdx`` lists ``anyOf``/``oneOf`` among the legal + keywords and calls a union entry's ``variants[]`` "the fastest recovery + path". What the pin never does is put one at the document root — in its + example the combinator entry's pointer is ``/account``, the union-valued + FIELD. Only ordering is constrained here; nothing is dropped. + """ + issues = payload["issues"] + assert pointer_to_field(issues[0]["pointer"]), issues + assert payload["field"] == pointer_to_field(issues[0]["pointer"]) + assert {i["pointer"] for i in issues} == {i["pointer"] for i in payload["details"]["issues"]} diff --git a/tests/test_error_narrowing.py b/tests/test_error_narrowing.py index 813aa1eaf..085654897 100644 --- a/tests/test_error_narrowing.py +++ b/tests/test_error_narrowing.py @@ -21,6 +21,7 @@ from typing import Any import pytest +from pydantic import BaseModel, ValidationError from adcp.types.error_narrowing import narrow_union_errors @@ -291,12 +292,22 @@ def test_narrow_returns_defensive_copies() -> None: assert original_err["msg"] == "Field required" -def test_narrow_passes_through_oversized_input() -> None: - """DoS guard: above 500 errors we don't run narrowing — narrowing - is UX, not correctness, and a hostile request shouldn't get to - amplify CPU through the bucketing logic. Verifies the cap is - enforced and the original list comes back unchanged.""" - # Build 600 errors that WOULD be narrowable if we ran the algorithm. +def test_narrow_keeps_every_arm_of_a_six_hundred_way_tie() -> None: + """600 arms, one ``missing`` each, all kept — because they TIE. + + This asserted a DoS cap: above 500 errors narrowing was skipped and the + input returned unchanged. The cap is gone (salesagent-8qela.75) — it made + quality degrade exactly when volume was highest, and narrowing measured + linear at ~4-5us per error, so there was no amplification to guard. + + The length is still 600, which is why the removal did not turn this red: + it now passes through the TIE path instead of the cap. Every arm has one + ``missing``, so none gets past its own requiredness, depths are equal, and + the count sort ties — ``_pick_winning_variant`` returns None and declines + to guess. Same number, different reason, so the docstring is corrected + rather than the assertion: a test passing for a mechanism that no longer + exists is worse than one failing. + """ errors: list[dict[str, Any]] = [ { "type": "missing", @@ -306,4 +317,103 @@ def test_narrow_passes_through_oversized_input() -> None: for i in range(600) ] narrowed = narrow_union_errors(errors) - assert len(narrowed) == 600 # bypass — input came back as-is + assert len(narrowed) == 600 + + +# --------------------------------------------------------------------------- +# The depth preference, pinned in both directions. +# +# PROVISIONAL, and the docstrings say so. Depth is NOT the principle: it fails +# a count-tied depth-differing case (salesagent-8qela.79) while a count-only +# rule fails the case below, and no ordering of the two satisfies both. The +# signal both cases actually turn on is which arm's DECLARED FIELDS the +# document carries, which the narrowing cannot see because it receives error +# dicts and never the arm's model (salesagent-8qela.89). +# +# These pin TODAY'S behaviour anyway, because the alternative was measured: with +# no guard, a sweep over 275 generated-type items concluded the preference had +# nothing to decide and it was approved for deletion. The guard exists to make +# that deletion deliberate rather than silent, not to assert depth is right. +# +# Fixtures are declared here rather than taken from the SDK. The previous +# coverage went through generated request models, and the emitter change that +# collapsed a split root's branches took the behaviour out from under it. An +# adopter-shaped fixture cannot go stale that way, and it is the path the +# module's docstring names for adopters building typed models in platform +# method bodies. +# +# One holder serves both cases, and the equal-depth case asserts its own +# depths before narrowing — both from sdk-rootcause's 3ea04ebaf, which is the +# better shape: one model means the pair cannot disagree about what pydantic +# reports, and the depth assertion means a fixture that stops being a tie says +# so instead of quietly testing something else. +# --------------------------------------------------------------------------- + + +class InnerNeed(BaseModel): + need: int + + +class ShallowArm(BaseModel): + kind: str + + +class DeepArm(BaseModel): + kind: str + inner: InnerNeed + + +class DepthHolder(BaseModel): + choice: ShallowArm | DeepArm + + +def _union_errors(model: type[BaseModel], payload: dict[str, Any]) -> list[Any]: + with pytest.raises(ValidationError) as raised: + model.model_validate(payload) + return raised.value.errors(include_input=False, include_context=False, include_url=False) + + +def test_both_arms_survive_when_they_reach_the_same_depth() -> None: + """Equal depth is a genuine tie, and a tie keeps every arm. + + Neither arm read further than the other, so nothing in the document says + which was meant and narrowing must not choose. This is the invariant + against over-narrowing, and it holds under the depth preference because + depth is inert when depths are equal. + """ + errors = _union_errors(DepthHolder, {"choice": {"kind": 5, "inner": {"need": 1}}}) + depths = {len(tuple(error["loc"])) for error in errors} + assert depths == {3}, f"the fixture stopped being an equal-depth tie: {depths}" + + narrowed = [tuple(error["loc"]) for error in narrow_union_errors(errors)] + + assert narrowed == [ + ("choice", "ShallowArm", "kind"), + ("choice", "DeepArm", "kind"), + ], f"a tie must keep both arms, got {narrowed}" + + +def test_the_deeper_arm_wins_when_the_arms_reach_different_depths() -> None: + """Differing depths select the deeper arm, dropping the shallower one's errors. + + PINS CURRENT BEHAVIOUR, NOT A PRINCIPLE. The document carries ``inner``, a + key only ``DeepArm`` declares, so ``DeepArm`` is the arm that was meant — + and depth happens to pick it. Counting errors would pick ``ShallowArm`` + (one error against two) and report only ``kind``, hiding ``inner.need`` + until the buyer fixed ``kind`` and resubmitted: worse than not narrowing. + + So the DROP asserted here is correct for this input, and the rule that + produces it is not. Depth merely correlates with the real signal, which is + declared-field presence (salesagent-8qela.89). When that rule lands, this + test should be rewritten to assert it — deliberately, which is the whole + reason the case is graded at all. + """ + errors = _union_errors(DepthHolder, {"choice": {"kind": 5, "inner": {}}}) + depths = {len(tuple(error["loc"])) for error in errors} + assert depths == {3, 4}, f"the fixture stopped differing in depth: {depths}" + + narrowed = [tuple(str(part) for part in error["loc"]) for error in narrow_union_errors(errors)] + + assert all("ShallowArm" not in path for path in narrowed), narrowed + # The error a count-based rule would have hidden. + assert any(path[-1] == "need" for path in narrowed), narrowed diff --git a/tests/test_error_recovery_table.py b/tests/test_error_recovery_table.py new file mode 100644 index 000000000..b0db48508 --- /dev/null +++ b/tests/test_error_recovery_table.py @@ -0,0 +1,216 @@ +"""``AdcpError``'s recovery is the pinned classification, not a hardcoded default. + +``enums/error-code.json``'s ``enumMetadata`` block classifies all 120 spec codes, +and its own ``$comment`` is addressed at SDKs: "SDKs MUST consume this block +instead of parsing 'Recovery: X' from enumDescriptions prose [...] the recovery +classification embedded in that prose is normative and MUST match the value +here." + +This SDK already consumes it. :func:`adcp.server.helpers._build_standard_error_codes` +reads the block, fails closed on an unclassified code, and publishes +:data:`STANDARD_ERROR_CODES`; :func:`adcp.server.helpers.adcp_error` resolves a +caller's omitted ``recovery`` through it. So there is one owner and it is derived +rather than declared. + +:class:`adcp.decisioning.types.AdcpError` — the exception adopters actually raise +— bypassed that owner and defaulted ``recovery="terminal"``. Two consequences, +and the tests below are one each: + +* **A raise site that states no recovery published ``terminal`` for every code.** + 101 of the 120 spec codes are registered ``correctable``, so the default was + wrong far more often than right. ``recovery`` exists so a buyer that does not + recognise a code can still classify it, and ``terminal`` means "no autonomous + recovery, operator intervention required" where ``correctable`` means "fix the + request and resend" — so the default told buyers to stop retrying things they + could have fixed. +* **An unregistered code keeps ``terminal``.** ``INTERNAL_ERROR`` is raised at 55 + sites and is in neither the spec enum nor the SDK extensions, so it has no + registered classification. ``adcp_error`` already answers ``terminal`` for an + unregistered code and ``AdcpError`` now agrees with it. This deliberately + differs from :func:`adcp.signing.recovery_for`, which RAISES for an + unclassified code: that taxonomy has no unregistered-code legacy to serve, and + raising here would break 55 live raise sites rather than classify them. + +An explicit ``recovery=`` still wins, because an adopter has to be able to +override a classification for their own deployment. What the third test forbids +is an override inside THIS package that merely restates or contradicts the +registered value — the 53 contradictions and 70 restatements this change removed. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from adcp.decisioning.types import AdcpError +from adcp.server.helpers import STANDARD_ERROR_CODES + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_SRC = _REPO_ROOT / "src" / "adcp" + +#: ``retry_with_changes`` is a legacy alias ``AdcpError`` still accepts for +#: ``correctable``; the pin registers only the three canonical values. +_ALIAS = {"retry_with_changes": "correctable"} + + +def test_every_registered_code_derives_its_registered_recovery() -> None: + """With no ``recovery`` stated, the code's registered classification is used.""" + assert len(STANDARD_ERROR_CODES) >= 100, ( + f"only {len(STANDARD_ERROR_CODES)} codes registered — the table has stopped " + "resolving and this test grades nothing" + ) + + wrong = [] + for code, info in sorted(STANDARD_ERROR_CODES.items()): + got = AdcpError(code).recovery + if got != info["recovery"]: + wrong.append(f"{code}: AdcpError gives {got}, the pin registers {info['recovery']}") + + assert wrong == [], "These codes do not derive their registered recovery:\n" + "\n".join( + f" {line}" for line in wrong + ) + + +def test_an_unregistered_code_is_terminal_and_agrees_with_adcp_error() -> None: + """An unregistered code has no classification to derive, so it stays terminal.""" + assert "INTERNAL_ERROR" not in STANDARD_ERROR_CODES, ( + "INTERNAL_ERROR is now registered, so this test no longer covers the " + "unregistered-code path — pick another unregistered code" + ) + assert AdcpError("INTERNAL_ERROR").recovery == "terminal" + assert AdcpError("X_VENDOR_UNKNOWN").recovery == "terminal" + + +@pytest.mark.parametrize("recovery", ["transient", "correctable", "terminal"]) +def test_an_explicit_recovery_still_overrides_the_table(recovery: str) -> None: + """An adopter's stated classification wins over the registered one.""" + # VALIDATION_ERROR is registered correctable, so for two of the three values + # this asserts the override is honoured against a DIFFERING registered value. + assert AdcpError("VALIDATION_ERROR", recovery=recovery).recovery == recovery + + +def _stated_recovery_sites() -> list[tuple[str, int, str, str]]: + """Every in-package call stating both a literal code and a literal recovery. + + The code is the ``code=`` keyword OR the first positional argument: reading + only the keyword finds nothing, because every call site passes it + positionally. + """ + found: list[tuple[str, int, str, str]] = [] + for path in sorted(_SRC.rglob("*.py")): + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError: + continue + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = func.id if isinstance(func, ast.Name) else getattr(func, "attr", None) + if name not in {"AdcpError", "adcp_error"}: + continue + + code_node: ast.AST | None = next( + (kw.value for kw in node.keywords if kw.arg == "code"), None + ) + if code_node is None and node.args: + code_node = node.args[0] + recovery_node = next((kw.value for kw in node.keywords if kw.arg == "recovery"), None) + if code_node is None or recovery_node is None: + continue + if not (isinstance(code_node, ast.Constant) and isinstance(code_node.value, str)): + continue + if not ( + isinstance(recovery_node, ast.Constant) and isinstance(recovery_node.value, str) + ): + continue + found.append( + ( + str(path.relative_to(_REPO_ROOT)), + node.lineno, + code_node.value, + recovery_node.value, + ) + ) + return found + + +def _restatements(sites: list[tuple[str, int, str, str]]) -> list[str]: + """Sites whose stated recovery EQUALS the registered one. The forbidden set.""" + found = [] + for rel, line, code, stated in sites: + info = STANDARD_ERROR_CODES.get(code) + if info is None: + continue # unregistered: the table owns nothing to restate + if _ALIAS.get(stated, stated) == info["recovery"]: + found.append(f"{rel}:{line}: {code} states {stated!r}, which is what the pin registers") + return found + + +def test_no_site_in_this_package_restates_the_registered_recovery() -> None: + """A stated recovery EQUAL to the registered one is a copy, and copies drift. + + Scoped to restatements on purpose, and the scope is the whole point. A + CONTRADICTION is not automatically a defect here: this package raises + several buyer-facing codes from boot-time and wiring validators of the + ADOPTER's own configuration, where the registered classification is wrong + for the audience. ``validate_webhook_signing_for_capabilities`` annotates + its own raises "boot-time validators (terminal)"; + ``validate_capabilities._violation`` says "Same shape as the other + server-boot fail-fast errors (terminal recovery + structured details)". A + buyer cannot fix "DecisioningPlatform.accounts is None — subclasses MUST set + an AccountStore" by resending, so ``terminal`` there is a judgement, not a + mistake, and those errors do reach the wire — four of those validators run + inside ``get_adcp_capabilities`` (salesagent-8qela.54). + + An earlier version of this test forbade EVERY stated recovery. That would + have made the correct state fail the build, so the next person would have + "fixed" the code to satisfy the guard and published ``correctable`` at ~45 + sites where a buyer can do nothing. A guard that cannot tell a justified + override from a copy enforces the regression it was written to prevent. + + A restatement has no such defence: it says exactly what the table says, so + it cannot be read as an override, and when the pin's classification moves it + becomes a silent contradiction. The pin WILL move — this epic exists because + of a version bump. + """ + offenders = _restatements(_stated_recovery_sites()) + assert offenders == [], ( + f"{len(offenders)} site(s) restate a recovery the table already owns; delete the " + "`recovery=` argument and let the constructor derive it:\n" + + "\n".join(f" {line}" for line in sorted(offenders)) + ) + + +def test_the_restatement_guard_distinguishes_a_copy_from_a_justified_override() -> None: + """The guard above must fire on a restatement and stay silent on an override. + + This is the guard's own fire probe, and it exists because the first version + of that guard could not make this distinction — it flagged both, which is + the failure mode that enforces a regression. Run against synthetic rows + rather than the tree so it keeps testing the discrimination after every real + restatement is gone. + """ + correctable = next(c for c, i in STANDARD_ERROR_CODES.items() if i["recovery"] == "correctable") + terminal = next(c for c, i in STANDARD_ERROR_CODES.items() if i["recovery"] == "terminal") + + # A copy of the registered value: must be caught. + assert _restatements( + [("f.py", 1, correctable, "correctable")] + ), "the guard did not catch a restatement — it grades nothing" + # The legacy alias is still a restatement of `correctable`. + assert _restatements( + [("f.py", 1, correctable, "retry_with_changes")] + ), "the guard did not recognise retry_with_changes as a restatement of correctable" + # A justified override contradicting the registry: must NOT be caught. + assert _restatements([("f.py", 1, correctable, "terminal")]) == [], ( + "the guard flagged a CONTRADICTION — this is the version that would enforce the " + "regression at the adopter-configuration validators" + ) + assert ( + _restatements([("f.py", 1, terminal, "correctable")]) == [] + ), "the guard flagged a contradiction in the other direction" + # An unregistered code has nothing to restate. + assert _restatements([("f.py", 1, "X_VENDOR_UNKNOWN", "terminal")]) == [] diff --git a/tests/test_extra_policy.py b/tests/test_extra_policy.py index 5e8465ffd..7b8fd75dd 100644 --- a/tests/test_extra_policy.py +++ b/tests/test_extra_policy.py @@ -10,6 +10,7 @@ from __future__ import annotations +import ast import json import re from pathlib import Path @@ -95,9 +96,13 @@ def test_open_payload_field_preserves_extension_data(self) -> None: "custom_targeting": {"segments": ["sports_fans"], "score": 0.92}, } + # A payload makes this an intent check, and governance/check-governance-request.json + # states that an intent check names the tool and the agent it targets. obj = CheckGovernanceRequest( plan_id="plan_1", caller="https://buyer.example", + tool="create_media_buy", + target_agent="https://seller.example", payload=payload, ) @@ -322,6 +327,62 @@ def _load_schemas() -> dict[str, Any]: all_schemas[underscore_key] = schema return all_schemas + @staticmethod + def _declares_extra_allow(source: str) -> bool: + """Whether this module DECLARES ``extra='allow'``, by parsing it. + + A substring search over the file text cannot tell a declaration from a + mention, and the branch emitter writes the string in prose: every + generated branch class carries a comment explaining why its + ``mode="before"`` validator is needed, and the explanation names + ``extra='allow'``. Three generated modules mention it that way while + declaring ``extra='forbid'``, so a text match asked the wrong question + of all three -- flagging one against its closed schema, and passing the + other two only because their schemas happen to be open. + """ + try: + tree = ast.parse(source) + except SyntaxError: # pragma: no cover - generated code parses + return False + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and getattr(node.func, "id", getattr(node.func, "attr", "")) == "ConfigDict" + ): + for keyword in node.keywords: + if ( + keyword.arg == "extra" + and isinstance(keyword.value, ast.Constant) + and keyword.value.value == "allow" + ): + return True + if isinstance(node, ast.Dict): + for key, value in zip(node.keys, node.values, strict=False): + if ( + isinstance(key, ast.Constant) + and key.value == "extra" + and isinstance(value, ast.Constant) + and value.value == "allow" + ): + return True + return False + + def test_a_mention_of_extra_allow_is_not_a_declaration(self) -> None: + """The detector reads the config, not the file's prose. + + Both halves asserted: the generated comment that made this necessary + must not count, and a real ``ConfigDict`` must. + """ + mention = ( + "class X:\n" + " model_config = ConfigDict(extra='forbid')\n" + " # a raw document still needs this: extra='allow' files an\n" + " # undeclared key under extras and accepts\n" + ) + assert not self._declares_extra_allow(mention) + assert self._declares_extra_allow("model_config = ConfigDict(extra='allow')") + assert self._declares_extra_allow("model_config = {'extra': 'allow'}") + def test_no_spurious_extra_allow(self) -> None: """Generated types with extra='allow' must have schema support for open extras.""" all_schemas = self._load_schemas() @@ -331,18 +392,25 @@ def test_no_spurious_extra_allow(self) -> None: } spurious = [] + graded = 0 for py_file in sorted(GENERATED_DIR.rglob("*.py")): if py_file.name == "__init__.py": continue content = py_file.read_text() - if "extra='allow'" not in content and 'extra="allow"' not in content: + if not self._declares_extra_allow(content): continue m = re.search(r"filename:\s+(.+)", content) if m: schema_name = m.group(1).strip() - if schema_name in schema_allows and not schema_allows[schema_name]: - spurious.append(f"{py_file.name} <- {schema_name}") - + if schema_name in schema_allows: + graded += 1 + if not schema_allows[schema_name]: + spurious.append(f"{py_file.name} <- {schema_name}") + + # A detector that matches nothing grades nothing and reports a clean + # pass, which is how this check would fail silently if the AST walk + # above ever stopped recognising the generated form. + assert graded > 400, f"only {graded} generated modules were graded; the detector is broken" assert ( not spurious ), "Generated files have extra='allow' without schema support:\n" + "\n".join( diff --git a/tests/test_feed_mirror.py b/tests/test_feed_mirror.py index 380ae65ee..65f009bd2 100644 --- a/tests/test_feed_mirror.py +++ b/tests/test_feed_mirror.py @@ -14,7 +14,7 @@ from typing import Any import pytest -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from adcp import ( FeedMirror, @@ -70,10 +70,13 @@ def make_product_dict( def make_signal_dict(segment_id: str, *, cpm: float = 2.5) -> dict[str, Any]: return { - # core/signal-listing.json requires signal_ref or signal_id. + # core/signal-listing.json requires signal_ref or signal_id, and its + # root ``allOf`` adds: a product-scoped ref defines the signal inline, + # so ``name`` and ``value_type`` are required alongside it. "signal_ref": {"scope": "product", "signal_id": segment_id}, "signal_agent_segment_id": segment_id, "name": f"Signal {segment_id}", + "value_type": "binary", "description": f"Description for {segment_id}", "signal_type": "marketplace", "data_provider": "Acme Data", @@ -121,6 +124,12 @@ def make_webhook( } if previous is not None: body["previous_wholesale_feed_version"] = previous + # core/wholesale-feed-webhook.json allOf[0]: a product.* fire echoes the + # representation the subscriber negotiated, and a non-product fire must not + # carry one at all. These payloads use ``product``/``pricing_options``, which + # is the legacy view. + if body["notification_type"].startswith("product."): + body["product_payload_view"] = "legacy" return WholesaleFeedWebhook.model_validate(body) @@ -129,6 +138,28 @@ def make_webhook( # --------------------------------------------------------------------------- +def _omit_scope_if_requested(model: Any, body: dict[str, Any]) -> Any: + """Build the response, honouring ``cache_scope: None`` as "the seller omitted it". + + ``FeedMirror``'s 8.x compatibility path exists for a seller that returns no + ``cache_scope`` -- it warns and inherits the cached scope. Such a response is + one the 3.2 schema refuses twice over: ``cache_scope`` is + ``enum: ["public", "account"]``, so a null is a type error, and the standard + response branch requires the key. So the document cannot be produced by + validating it, and a fixture that wrote ``cache_scope: None`` was only + accepted while the compiled check read membership rather than presence on + the document SENT. + + Everything else is validated; the scope is cleared afterwards, which is what + the mirror sees from a seller that never sent it. The subject under test is + the mirror's tolerance, not the model's acceptance. + """ + if body.get("cache_scope") is not None: + return model.model_validate(body) + validated = model.model_validate({**body, "cache_scope": "public"}) + return validated.model_copy(update={"cache_scope": None}) + + class StubClient: """Records requests and replays scripted GetProducts/GetSignals responses. @@ -156,7 +187,7 @@ async def get_products(self, request: GetProductsRequest) -> TaskResult[GetProdu return TaskResult( status=TaskStatus.COMPLETED, success=True, - data=GetProductsResponse.model_validate(body), + data=_omit_scope_if_requested(GetProductsResponse, body), ) async def get_signals(self, request: GetSignalsRequest) -> TaskResult[GetSignalsResponse]: @@ -167,7 +198,7 @@ async def get_signals(self, request: GetSignalsRequest) -> TaskResult[GetSignals return TaskResult( status=TaskStatus.COMPLETED, success=True, - data=GetSignalsResponse.model_validate(body), + data=_omit_scope_if_requested(GetSignalsResponse, body), ) @@ -895,19 +926,36 @@ async def test_webhook_scope_mismatch_keeps_all_layers_unchanged(envelope_scope, overlay = public.for_account( TypeAdapter(AccountReference).validate_python({"account_id": "acc_acme"}) ) - event = make_event( - "018f0000-0000-7000-8000-000000000051", - "product.created", - "product", - "private", - { - "product_id": "private", - "product": make_product_dict("private"), - "applies_to": {"scope": payload_scope}, - }, - ) + + # core/wholesale-feed-webhook.json allOf members 10 and 11 tie cache_scope to + # the payload's applies_to.scope in both directions, so a mismatching document + # never validates. There are TWO layers here and each one is graded, because + # neither implies the other: the model refuses the mismatched document where + # the webhook is built, and the mirror keeps its own check for an + # already-typed webhook. That second check cannot be reached by a document + # the model would refuse, so it is reached the way the notification_type one + # is -- by corrupting a webhook the model accepted (see the + # ``object.__setattr__`` on notification_type below). + def _event(scope: str) -> dict[str, Any]: + return make_event( + "018f0000-0000-7000-8000-000000000051", + "product.created", + "product", + "private", + { + "product_id": "private", + "product": make_product_dict("private"), + "applies_to": {"scope": scope}, + }, + ) + + with pytest.raises(ValidationError, match="applies_to"): + make_webhook(_event(payload_scope), cache_scope=envelope_scope) + + webhook = make_webhook(_event(envelope_scope), cache_scope=envelope_scope) + object.__setattr__(webhook.event.payload.applies_to, "scope", payload_scope) with pytest.raises(FeedMirrorError, match="applies_to.scope"): - await overlay.apply_webhook(make_webhook(event, cache_scope=envelope_scope)) + await overlay.apply_webhook(webhook) assert set(public.products) == {"p1"} assert overlay.get_product("private") is None assert overlay.product_state.wholesale_feed_version is None diff --git a/tests/test_forward_compat_assets.py b/tests/test_forward_compat_assets.py index 320e7e3a0..a07f5e2f8 100644 --- a/tests/test_forward_compat_assets.py +++ b/tests/test_forward_compat_assets.py @@ -1,8 +1,17 @@ """Regression tests for issue #742: open-union forward compatibility. -Verifies that Format.assets and RepeatableAssetGroup.assets tolerate novel -asset_type values (e.g., 'pixel_tracker') without raising ValidationError, -and that known asset types still parse correctly with full type narrowing. +Verifies that Format.assets and RepeatableAssetGroup.assets tolerate an +asset_type this pin has no class for, without raising ValidationError, and that +every asset_type the pin DOES have a class for still parses as that class. + +The novel value is DERIVED, not written down. Four tests here used +``pixel_tracker`` as their stand-in novel type and asserted it parsed as +``UnknownFormatAsset``. ``pixel_tracker`` is declared by the pinned schema and +has a generated class (``Assets26``), so those assertions were encoding +salesagent-d2wuv: the vocabulary the union routed by was a hand-written literal +that had fallen 7 behind the generated classes, and the test agreed with the +literal. A hardcoded "novel" value can become declared by a schema refresh +without the test noticing; a derived one cannot. """ from __future__ import annotations @@ -18,6 +27,7 @@ UnknownFormatAsset, UnknownGroupAsset, ) +from adcp.types.domains.core import format as format_module from adcp.types.domains.core.format import Assets94, Format @@ -25,8 +35,41 @@ def _make_format_id(name: str) -> dict: return {"agent_url": "https://test.example.com", "id": name} +def _declared_asset_types(base: type) -> dict[str, type]: + """``asset_type`` -> the class this pin generated for it, for one asset base. + + Read off the live classes rather than listed, which is the whole point: the + union's own vocabulary is derived the same way, so a schema refresh moves + both together. Asserted non-empty because a derivation that matches nothing + passes everything. + """ + found = { + value.model_fields["asset_type"].default: value + for value in vars(format_module).values() + if isinstance(value, type) + and issubclass(value, base) + and value is not base + and "asset_type" in getattr(value, "model_fields", {}) + and isinstance(value.model_fields["asset_type"].default, str) + } + assert found, f"no generated subclass of {base.__name__} declares an asset_type" + return found + + +def _an_undeclared_asset_type(base: type) -> str: + """An ``asset_type`` this pin has no class for, derived from the ones it has.""" + declared = _declared_asset_types(base) + candidate = "x_future_asset_type" + assert candidate not in declared, "the derived novel value became declared" + return candidate + + +_UNDECLARED_INDIVIDUAL = _an_undeclared_asset_type(format_module.BaseIndividualAsset) +_UNDECLARED_GROUP = _an_undeclared_asset_type(format_module.BaseGroupAsset) + + class TestUnknownFormatAssetParsing: - """Format.assets accepts novel asset_type values after _forward_compat patch.""" + """Format.assets accepts an asset_type this pin has no class for.""" def test_novel_asset_type_parses_as_unknown(self): """A novel asset_type that is not in the SDK parses as UnknownFormatAsset.""" @@ -37,7 +80,7 @@ def test_novel_asset_type_parses_as_unknown(self): "assets": [ { "asset_id": "impression_tracker", - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_INDIVIDUAL, "item_type": "individual", "required": False, }, @@ -48,7 +91,7 @@ def test_novel_asset_type_parses_as_unknown(self): assert len(fmt.assets) == 1 asset = fmt.assets[0] assert isinstance(asset, UnknownFormatAsset) - assert asset.asset_type == "pixel_tracker" + assert asset.asset_type == _UNDECLARED_INDIVIDUAL assert asset.asset_id == "impression_tracker" def test_known_asset_type_still_parses_correctly(self): @@ -87,7 +130,7 @@ def test_mixed_known_and_unknown_assets_parse(self): }, { "asset_id": "tracker", - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_INDIVIDUAL, "item_type": "individual", "required": False, }, @@ -104,7 +147,7 @@ def test_mixed_known_and_unknown_assets_parse(self): assert len(fmt.assets) == 3 assert isinstance(fmt.assets[0], ImageFormatAsset) assert isinstance(fmt.assets[1], UnknownFormatAsset) - assert fmt.assets[1].asset_type == "pixel_tracker" + assert fmt.assets[1].asset_type == _UNDECLARED_INDIVIDUAL def test_unknown_asset_preserves_extra_fields(self): """Extra fields on unknown assets are preserved via extra='allow'.""" @@ -115,7 +158,7 @@ def test_unknown_asset_preserves_extra_fields(self): "assets": [ { "asset_id": "track", - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_INDIVIDUAL, "item_type": "individual", "required": False, "tracking_url": "https://example.com/track", @@ -191,7 +234,7 @@ def test_novel_group_asset_parses_as_unknown(self): "assets": [ { "asset_id": "track", - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_GROUP, "required": False, }, ], @@ -199,7 +242,7 @@ def test_novel_group_asset_parses_as_unknown(self): ) assert len(group.assets) == 1 assert isinstance(group.assets[0], UnknownGroupAsset) - assert group.assets[0].asset_type == "pixel_tracker" + assert group.assets[0].asset_type == _UNDECLARED_GROUP def test_known_group_asset_still_parses(self): """Known asset_type inside a group still routes to the typed class.""" @@ -304,3 +347,70 @@ def test_imports_from_adcp_types(self): assert UnknownGroupAsset is not None assert FormatAssetUnion is not None assert GroupFormatAssetUnion is not None + + +class TestDeclaredAssetTypesAreNotDegraded: + """Every asset_type this pin generated a class for must reach that class. + + The open union exists so an asset_type defined AFTER this pin survives. It + must not swallow the ones defined at it. The vocabulary it routes by was a + hand-written literal 7 behind the generated individual classes and 9 behind + the group ones, so seven individual and nine group types arrived as + UnknownFormatAsset / UnknownGroupAsset with their typed fields in + __pydantic_extra__ (salesagent-d2wuv). The vocabulary is now derived from the + branches themselves, and this grades that end to end. + + Parametrized over the derived vocabulary rather than a list, so an asset type + a schema refresh adds is covered the moment its class is generated. + """ + + @pytest.mark.parametrize( + "asset_type,expected", + sorted(_declared_asset_types(format_module.BaseIndividualAsset).items()), + ) + def test_each_declared_individual_type_reaches_its_class( + self, asset_type: str, expected: type + ) -> None: + fmt = Format.model_validate( + { + "format_id": _make_format_id("declared"), + "name": "Declared", + "assets": [ + { + "asset_id": "slot", + "asset_type": asset_type, + "item_type": "individual", + "required": True, + } + ], + } + ) + assert fmt.assets is not None + asset = fmt.assets[0] + assert type(asset) is expected + assert not isinstance(asset, UnknownFormatAsset) + # A degraded asset keeps its type-specific fields as untyped extras, so + # an empty extra dict is part of what "reached its class" means. + assert not (asset.__pydantic_extra__ or {}) + + @pytest.mark.parametrize( + "asset_type,expected", + sorted(_declared_asset_types(format_module.BaseGroupAsset).items()), + ) + def test_each_declared_group_type_reaches_its_class( + self, asset_type: str, expected: type + ) -> None: + group = Assets94.model_validate( + { + "asset_group_id": "slide", + "item_type": "repeatable_group", + "required": True, + "min_count": 1, + "max_count": 5, + "assets": [{"asset_id": "slot", "asset_type": asset_type, "required": True}], + } + ) + slot = group.assets[0] + assert type(slot) is expected + assert not isinstance(slot, UnknownGroupAsset) + assert not (slot.__pydantic_extra__ or {}) diff --git a/tests/test_forward_compat_format_kind.py b/tests/test_forward_compat_format_kind.py index 328b7e637..ee01e18c7 100644 --- a/tests/test_forward_compat_format_kind.py +++ b/tests/test_forward_compat_format_kind.py @@ -59,12 +59,24 @@ FUTURE_FORMAT_KIND = "future_canonical_format" +def _coordinated(format_kind: str) -> dict[str, object]: + """``component_assets`` exactly when the kind is ``coordinated_placements``. + + ``creative-asset.json`` and ``creative-manifest.json`` both state it as one + conditional: if the kind is ``coordinated_placements`` the manifest carries + ``component_assets``, and otherwise it MUST NOT. So a factory parametrized + over every kind has to follow the kind rather than pick one side. + """ + return {"component_assets": {}} if format_kind == "coordinated_placements" else {} + + def _creative_asset(format_kind: str) -> CreativeAsset: return CreativeAsset( creative_id="creative-1", name="Creative", format_kind=format_kind, assets={}, + **_coordinated(format_kind), ) @@ -89,7 +101,7 @@ def _delivery_creative(format_kind: str) -> DeliveryCreative: def _creative_manifest(format_kind: str) -> CreativeManifest: - return CreativeManifest(format_kind=format_kind, assets={}) + return CreativeManifest(format_kind=format_kind, assets={}, **_coordinated(format_kind)) #: Every model that carries a ``format_kind``, in both directions. There is no @@ -212,11 +224,18 @@ def test_manifest_format_kind_keeps_its_optional_default() -> None: with pytest.raises(ValidationError): CreativeManifest(assets={}) - # An explicit ``None`` satisfies the document rule -- the group is keyed on the - # field being present, not on it being non-null -- and round-trips as null. - explicit = CreativeManifest(assets={}, format_kind=None) - assert explicit.format_kind is None - assert explicit.model_dump(exclude_unset=True, exclude_none=False)["format_kind"] is None + # An explicit ``None`` does NOT satisfy the document rule. It used to, while + # the compiled check read membership alone, and that was wrong in two ways + # the bundled schema states plainly: ``format_kind`` is an enum of strings, + # so ``null`` is not a value it takes, and the client drops a null-valued key + # on the way out -- so the document SENT carries neither arm's field. The + # group is keyed on the field being present on what is sent. + with pytest.raises(ValidationError): + CreativeManifest(assets={}, format_kind=None) + + # Naming the arm is what satisfies it. + named = CreativeManifest(assets={}, format_kind="image") + assert named.format_kind == "image" @pytest.mark.parametrize("json_input", [False, True], ids=["python", "json"]) diff --git a/tests/test_generate_types_safety.py b/tests/test_generate_types_safety.py index 63d75ec46..a7acfb00e 100644 --- a/tests/test_generate_types_safety.py +++ b/tests/test_generate_types_safety.py @@ -62,20 +62,24 @@ def _configure_fake_pipeline( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *, - fail_ergonomic: bool, -) -> tuple[Path, Path, Path, Path]: + fail_late: bool, +) -> tuple[Path, Path, Path]: + """A pipeline whose last subprocess can be made to fail. + + The injection point used to be the ergonomic-coercion step, which was the + last subprocess the pipeline ran. That step is gone -- the coercion is + declared on the generated field (salesagent-1jj5e) -- so consolidation is + now the last one, and it carries the same property: a failure AFTER + generation has produced a new tree must leave the checkout untouched. + """ repository = tmp_path / "repository" output = repository / "src" / "adcp" / "types" / "domains" consolidated = output.parent / "_generated.py" - ergonomic = output.parent / "_ergonomic.py" deltas = repository / "SCHEMA_DELTAS.md" output.mkdir(parents=True) - scripts = repository / "scripts" - scripts.mkdir() - (scripts / "generate_ergonomic_coercion.py").write_text("") + (repository / "scripts").mkdir() (output / "sentinel.py").write_text("old generated tree\n") consolidated.write_text("old consolidated exports\n") - ergonomic.write_text("old ergonomic module\n") deltas.write_text("old delta report\n") monkeypatch.setattr(generate_types, "REPO_ROOT", repository) @@ -105,53 +109,43 @@ def fake_staged_source(staging_root: Path) -> Path: types = source / "adcp" / "types" types.mkdir(parents=True) (types / "_generated.py").write_text("stale consolidated exports\n") - (types / "_ergonomic.py").write_text("stale ergonomic module\n") return source monkeypatch.setattr(generate_types, "_staged_source_tree", fake_staged_source) def fake_subprocess(args: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: - if "consolidate_exports.py" in args[1]: - destination = Path(args[args.index("--output-file") + 1]) - destination.write_text("new consolidated exports\n") - return subprocess.CompletedProcess(args, 0, "", "") - if fail_ergonomic: - return subprocess.CompletedProcess(args, 1, "", "late ergonomic failure") + assert "consolidate_exports.py" in args[1], args[1] + if fail_late: + return subprocess.CompletedProcess(args, 1, "", "late consolidation failure") destination = Path(args[args.index("--output-file") + 1]) - destination.write_text("new ergonomic module\n") + destination.write_text("new consolidated exports\n") return subprocess.CompletedProcess(args, 0, "", "") monkeypatch.setattr(generate_types.subprocess, "run", fake_subprocess) - return output, consolidated, ergonomic, deltas + return output, consolidated, deltas def test_late_generation_failure_leaves_checkout_unchanged( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - output, consolidated, ergonomic, deltas = _configure_fake_pipeline( - monkeypatch, tmp_path, fail_ergonomic=True - ) + output, consolidated, deltas = _configure_fake_pipeline(monkeypatch, tmp_path, fail_late=True) assert generate_types.main([]) == 1 assert (output / "sentinel.py").read_text() == "old generated tree\n" assert consolidated.read_text() == "old consolidated exports\n" - assert ergonomic.read_text() == "old ergonomic module\n" assert deltas.read_text() == "old delta report\n" def test_check_mode_reports_drift_without_writes( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - output, consolidated, ergonomic, deltas = _configure_fake_pipeline( - monkeypatch, tmp_path, fail_ergonomic=False - ) + output, consolidated, deltas = _configure_fake_pipeline(monkeypatch, tmp_path, fail_late=False) assert generate_types.main(["--check"]) == 1 assert (output / "sentinel.py").read_text() == "old generated tree\n" assert consolidated.read_text() == "old consolidated exports\n" - assert ergonomic.read_text() == "old ergonomic module\n" assert deltas.read_text() == "old delta report\n" diff --git a/tests/test_generated_hierarchy.py b/tests/test_generated_hierarchy.py index eb47cecc3..9d32c6a20 100644 --- a/tests/test_generated_hierarchy.py +++ b/tests/test_generated_hierarchy.py @@ -38,7 +38,8 @@ from adcp.validation.version import resolve_bundle_key from scripts import generate_types as codegen -from scripts.post_generate_fixes import _resolve_schema_ref, _schema_title_to_class_name +from scripts.generated_names import generated_root_class_name +from scripts.post_generate_fixes import _resolve_schema_ref _REPO_ROOT = Path(__file__).resolve().parent.parent _GENERATED_DIR = _REPO_ROOT / "src" / "adcp" / "types" / "domains" @@ -119,7 +120,7 @@ def _compositions() -> tuple[_Composition, ...]: continue ref = entry.get("$ref") if not isinstance(ref, str): - continue # an inline constraint arm, not a composition + continue # an inline constraint branch, not a composition fragment = ref.split("#", 1)[1] if "#" in ref else "" if ref.startswith("#") or fragment not in ("", "/"): # A JSON pointer names a sub-schema, which has no generated @@ -167,7 +168,7 @@ def _root_names(rel: Path) -> tuple[tuple[str, ...], tuple[str, ...]] | None: class, as an alias assignment, or both. A root with ``oneOf`` beside its ``allOf`` also emits a ``NameN`` branch - class per arm, and those are collected **in addition to** the bare name, + class per branch, and those are collected **in addition to** the bare name, not instead of it. That is the whole of #1136: the composition was lost on 19 of 24 response branches while the top-level name was fine, so a resolver that stops at the name matching the schema file grades none of the classes @@ -178,10 +179,10 @@ class per arm, and those are collected **in addition to** the bare name, those it picks must not decide whether the branch is graded. ``N`` is the whole numeric run after the candidate, not one digit: a root - that fans out into ten or more arms emits ``Name10`` upward, and this tree + that fans out into ten or more branches emits ``Name10`` upward, and this tree carries 341 classes with a multi-digit suffix (``AcceptancePolicyRequirement17``, ``AdcpAgentsAuthorization213``). Matching a single trailing character would - drop those arms from the grading silently, which is the under-grading + drop those branches from the grading silently, which is the under-grading #1136 is about. Returning ``None`` rather than guessing at the last class in the file is @@ -210,8 +211,8 @@ class per arm, and those are collected **in addition to** the bare name, title = schema.get("title") candidates = [] if isinstance(title, str) and title.strip(): - candidates.append(_schema_title_to_class_name(title)) - candidates.append(_schema_title_to_class_name(rel.stem.replace("-", " "))) + candidates.append(generated_root_class_name(title)) + candidates.append(generated_root_class_name(rel.stem.replace("-", " "))) for candidate in candidates: root = tuple(name for name in (*classes, *bound) if name.lower() == candidate.lower()) @@ -229,7 +230,7 @@ class per arm, and those are collected **in addition to** the bare name, def _resolve(rel: Path, names: tuple[str, ...]) -> tuple[type, ...]: - """The classes behind module-level names, with unions flattened to arms. + """The classes behind module-level names, with unions flattened to branches. Deduplicated: a root that fans out is reachable both as its union alias and as the branch classes the alias is built from, and counting a class twice @@ -246,11 +247,13 @@ def _resolve(rel: Path, names: tuple[str, ...]) -> tuple[type, ...]: # carries the schema's discriminator, and the union is its first arg. if typing.get_origin(obj) is typing.Annotated: obj = typing.get_args(obj)[0] - arms = typing.get_args(obj) - assert arms, f"{_module_name(rel)}.{name} is neither a class nor a union: {obj!r}" - for arm in arms: - assert isinstance(arm, type), f"{_module_name(rel)}.{name} arm is not a class: {arm!r}" - found[arm] = None + branches = typing.get_args(obj) + assert branches, f"{_module_name(rel)}.{name} is neither a class nor a union: {obj!r}" + for branch in branches: + assert isinstance( + branch, type + ), f"{_module_name(rel)}.{name} arm is not a class: {branch!r}" + found[branch] = None return tuple(found) @@ -457,7 +460,7 @@ def test_public_surface_keeps_the_composed_ancestry() -> None: assert checked, "no graded class is exported from adcp.types; the lookup is broken" -def test_response_arm_aliases_keep_the_protocol_envelope() -> None: +def test_response_branch_aliases_keep_the_protocol_envelope() -> None: """Every ``*SuccessResponse`` / ``*ErrorResponse`` alias is a ProtocolEnvelope. This is #1136 stated as its own assertion: ``oneOf`` response branches lost @@ -471,27 +474,97 @@ def test_response_arm_aliases_keep_the_protocol_envelope() -> None: ``ProtocolEnvelope`` is named here rather than derived. Deriving it would mean mapping an alias name back to a schema file by convention, which is the guesswork the rest of this module avoids; the obligation is the same - for every arm of every task response, so naming it once is auditable. + for every branch of every task response, so naming it once is auditable. """ from adcp.types import aliases from adcp.types.domains.core.protocol_envelope import ProtocolEnvelope - arms = sorted( + branches = sorted( name for name in aliases.__all__ if name.endswith(("SuccessResponse", "ErrorResponse")) ) - assert len(arms) >= _MIN_RESPONSE_ARM_ALIASES, ( - f"found only {len(arms)} response arm aliases, expected at least " + assert len(branches) >= _MIN_RESPONSE_ARM_ALIASES, ( + f"found only {len(branches)} response arm aliases, expected at least " f"{_MIN_RESPONSE_ARM_ALIASES} — the alias surface has moved and this test no " "longer grades it" ) lost = [ f"adcp.types.aliases.{name} ({cls.__module__}.{cls.__name__})" - for name in arms + for name in branches if not issubclass(cls := getattr(aliases, name), ProtocolEnvelope) ] assert lost == [], ( - "These response arm aliases are not ProtocolEnvelope subclasses, so the " + "These response branch aliases are not ProtocolEnvelope subclasses, so the " "root allOf composition is lost on the surface a consumer binds to — the " "#1136 regression:\n" + "\n".join(f" {line}" for line in lost) ) + + +# Measured at the 3.2.1 pin: of the 1056 titled roots in the generated population, +# 5 generate no module and 105 generate no single class the title identifies, so +# 946 are graded. A floor, for the reason the floors above are floors — and it is +# the population assertion the test below needs, because a derivation that +# resolves nothing compares an empty list and passes. +_MIN_NAMED_ROOTS = 900 + + +def test_the_root_class_name_derived_from_a_title_is_the_name_the_module_declares() -> None: + """A root's derived class name is the generated class's name, casing included. + + ``_schema_root_class_name`` is the root's identity: branch class names are + built from it, and a consumer that cannot read the generated module — a + capture index keyed by root, a name resolved before the branches exist — + has only it. So it is not enough that the name identify the class up to + case; it has to BE the name, or every such consumer is keying on a string + the module never declares. + + It was not. The helper kept the title's acronym casing while + ``datamodel-code-generator`` title-cases the root, so + ``"Get AdCP Capabilities Response"`` derived ``GetAdCPCapabilitiesResponse`` + against a module declaring ``GetAdcpCapabilitiesResponse`` — 94 of the 946 + roots graded here. Generation never noticed: ``_root_generated_class`` + resolves case-insensitively and returns the module's spelling. The capture + classifier, which keys an index on the derived name with no module to + resolve against, matched nothing for those roots and counted 48 documents + as "root not identified" (salesagent-5pe83). + + Case-insensitive on the way IN, exact on the way OUT: a root whose title + identifies no class generated no flat model and is not this test's subject, + so membership is decided up to case and the assertion is on the spelling. + """ + from scripts.post_generate_fixes import _schema_root_class_name, generated_module_for + + graded = 0 + mismatched: list[str] = [] + for rel in _generated_schemas(): + schema = json.loads((_SCHEMA_DIR / rel).read_text()) + if not isinstance(schema, dict): + continue + derived = _schema_root_class_name(schema) + if derived is None: + continue + module = generated_module_for(rel) + if not module.exists(): + continue + declared = { + node.name + for node in ast.parse(module.read_text()).body + if isinstance(node, ast.ClassDef) + } + matching = {name for name in declared if name.lower() == derived.lower()} + if len(matching) != 1: + continue + graded += 1 + if derived not in matching: + mismatched.append(f"{rel}: derived {derived}, module declares {matching.pop()}") + + assert graded >= _MIN_NAMED_ROOTS, ( + f"only {graded} roots name a generated class, expected at least " + f"{_MIN_NAMED_ROOTS} — the derivation has stopped resolving and this test " + "no longer grades anything" + ) + assert mismatched == [], ( + "These roots derive a class name the generated module does not declare, so " + "anything keying on the derived name misses the class:\n" + + "\n".join(f" {line}" for line in sorted(mismatched)) + ) diff --git a/tests/test_geo_place_system_keys.py b/tests/test_geo_place_system_keys.py index fe8a65a57..720e55a98 100644 --- a/tests/test_geo_place_system_keys.py +++ b/tests/test_geo_place_system_keys.py @@ -232,7 +232,9 @@ def test_overlay_product_and_discovery_parents(system: str, field: str, from_jso (Product, product, ["overlay_support", field, "systems"]), ( GetProductsResponse, - {"products": [product]}, + # media-buy/get-products-response.json's root states which field group + # a successful lookup carries: products alone is not one of them. + {"status": "completed", "cache_scope": "public", "products": [product]}, ["products", 0, "overlay_support", field, "systems"], ), ( diff --git a/tests/test_get_products_projection.py b/tests/test_get_products_projection.py index e967fb0d8..e5f90520d 100644 --- a/tests/test_get_products_projection.py +++ b/tests/test_get_products_projection.py @@ -60,7 +60,10 @@ def _make_product(**overrides: Any) -> Product: def _make_response(*products: Product) -> GetProductsResponse: - return GetProductsResponse(products=list(products)) + # ``cache_scope`` is required on every 3.1+ get_products response: the root + # conditional's standard branch names it alongside ``products``, and the + # two-layer cache model's safety property depends on it. + return GetProductsResponse(products=list(products), cache_scope="public") # --------------------------------------------------------------------------- @@ -227,6 +230,7 @@ def test_projection_preserves_other_response_fields() -> None: products=[product], property_list_applied=True, errors=None, + cache_scope="public", ) projected = _project_product_fields(response, [GetProductsField.product_id]) assert projected.property_list_applied is True @@ -259,7 +263,7 @@ class _Platform(DecisioningPlatform): accounts = SingletonAccounts(account_id="acct") async def get_products(self, req, ctx): - return GetProductsResponse(products=[product]) + return GetProductsResponse(products=[product], cache_scope="public") handler = _make_handler(_Platform(), executor) req = GetProductsRequest(buying_mode="brief", brief="any") @@ -279,7 +283,7 @@ class _Platform(DecisioningPlatform): accounts = SingletonAccounts(account_id="acct") async def get_products(self, req, ctx): - return GetProductsResponse(products=[product]) + return GetProductsResponse(products=[product], cache_scope="public") handler = _make_handler(_Platform(), executor) req = GetProductsRequest( @@ -302,7 +306,7 @@ class _Platform(DecisioningPlatform): accounts = SingletonAccounts(account_id="acct") async def get_products(self, req, ctx): - return GetProductsResponse(products=[product]) + return GetProductsResponse(products=[product], cache_scope="public") handler = _make_handler(_Platform(), executor) req = GetProductsRequest( @@ -328,7 +332,7 @@ class _Platform(DecisioningPlatform): accounts = SingletonAccounts(account_id="acct") async def get_products(self, req, ctx): - return GetProductsResponse(products=[product]) + return GetProductsResponse(products=[product], cache_scope="public") handler = _make_handler(_Platform(), executor) # Manually construct request with empty fields list to test the guard diff --git a/tests/test_governance_refusal_vocabulary.py b/tests/test_governance_refusal_vocabulary.py new file mode 100644 index 000000000..a9a4dd730 --- /dev/null +++ b/tests/test_governance_refusal_vocabulary.py @@ -0,0 +1,195 @@ +"""A governance refusal names its rejection class and its location on the wire. + +``schemas/cache/3.2/core/error.json`` (AdCP 3.2.1) makes ``keyword`` required on +every ``issues[]`` row, describes it as "drawn from the JSON Schema vocabulary +... do not invent custom values here", and states the purpose: an agent can +"pattern-match on rejection class without parsing message text". ``pointer`` is +"RFC 6901 JSON Pointer to the offending field". + +WHY THIS FILE EXISTS. ``invalid_request`` stamped ``keyword="validation"`` -- +not a vocabulary term -- and ``pointer="/"`` on every refusal that is not a +pydantic ``ValidationError``. That survived salesagent-8qela.106, which fixed one +path and left the shared emitter, and the reason it survived is that NOTHING +GRADED EITHER FIELD: the 26-site fix moved zero test outcomes across 19,291 +items, which proved no regression and equally proved no coverage. A one-off wire +measurement is not a guard, so this is the guard. + +It asserts on the ENVELOPE the peer receives, not on the exception, because the +exception is not what the pin constrains. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest +from jsonschema import Draft7Validator + +from adcp.governance import validate_governance_outcome_request, validate_governance_request +from adcp.server.base import invalid_request +from adcp.types import CheckGovernanceRequest, ReportPlanOutcomeRequest + +#: The admissible set, read from the validator that implements the vocabulary +#: rather than restated here, so a term this SDK emits cannot drift from the set +#: the pin points at. +VOCABULARY = frozenset(Draft7Validator.VALIDATORS) + + +def _rows(error: Exception, model: type[Any]) -> list[tuple[str, str]]: + """``(keyword, pointer)`` per issue, off the built envelope.""" + envelope = invalid_request(error, payload={}, request_model=model).model_dump(mode="json") + return [(issue["keyword"], issue["pointer"]) for issue in envelope["error"]["issues"]] + + +def _refuse(fn: Callable[..., Any], payload: dict[str, Any]) -> Exception: + with pytest.raises(ValueError) as raised: + fn(payload) + return raised.value + + +_INTENT_AND_EXECUTION = {"tool": "x", "planned_delivery": {}} +_MISSING_AND_FORBIDDEN = {"plan_id": "p1", "tool": "buy_products", "governance_context": {"a": 1}} +_NEEDS_COMMITMENT = { + "plan_id": "p1", + "tool": "buy_products", + "payload": {}, + "target_agent": "https://a.example", +} +_DELIVERY_NO_MEDIA_BUY = { + "planned_delivery": {}, + "phase": "delivery", + "governance_context": {"a": 1}, +} +_BUDGET_NO_CURRENCY = { + "planned_delivery": {"media_buy_id": "mb", "total_budget": 5}, + "governance_context": {"a": 1}, +} + +#: One case per vocabulary term this surface emits, each naming the EXACT rows. +#: Spelling the full row list rather than "contains" is deliberate: a regression +#: that collapses a compound refusal back to one row has to fail here. +CASES: tuple[ + tuple[str, Callable[..., Any], dict[str, Any], type[Any], list[tuple[str, str]]], ... +] = ( + ( + "oneOf: the two shapes are mutually exclusive", + validate_governance_request, + _INTENT_AND_EXECUTION, + CheckGovernanceRequest, + [("oneOf", "/")], + ), + ( + "anyOf: neither shape is selected", + validate_governance_request, + {"adcp_version": "3.2.1"}, + CheckGovernanceRequest, + [("anyOf", "/")], + ), + ( + "required + not: ONE site, TWO rules, two rows", + validate_governance_request, + _MISSING_AND_FORBIDDEN, + CheckGovernanceRequest, + [ + ("required", "/payload"), + ("required", "/target_agent"), + ("not", "/governance_context"), + ], + ), + ( + "dependencies: a conditional required key", + validate_governance_request, + _NEEDS_COMMITMENT, + CheckGovernanceRequest, + [("dependencies", "/proposed_commitment")], + ), + ( + "dependencies: a NESTED pointer, not the document root", + validate_governance_request, + _DELIVERY_NO_MEDIA_BUY, + CheckGovernanceRequest, + [("dependencies", "/planned_delivery/media_buy_id")], + ), + ( + "dependencies: one key required by a sibling's presence", + validate_governance_request, + _BUDGET_NO_CURRENCY, + CheckGovernanceRequest, + [("dependencies", "/planned_delivery/currency")], + ), + ( + "type: the field is the wrong primitive", + validate_governance_outcome_request, + {"outcome": 5}, + ReportPlanOutcomeRequest, + [("type", "/outcome")], + ), + ( + "enum: the value is outside the permitted set", + validate_governance_outcome_request, + {"outcome": "bogus"}, + ReportPlanOutcomeRequest, + [("enum", "/outcome")], + ), + ( + "required: ONE site, TWO missing keys, two rows", + validate_governance_outcome_request, + {"outcome": "completed", "seller_response": {"a": 1}}, + ReportPlanOutcomeRequest, + [("required", "/check_id"), ("required", "/governance_context")], + ), +) + + +@pytest.mark.parametrize( + ("fn", "payload", "model", "expected"), + [(fn, payload, model, expected) for _, fn, payload, model, expected in CASES], + ids=[label for label, *_ in CASES], +) +def test_a_refusal_names_its_rejection_class_and_its_location( + fn: Callable[..., Any], + payload: dict[str, Any], + model: type[Any], + expected: list[tuple[str, str]], +) -> None: + """The exact ``(keyword, pointer)`` rows the peer receives.""" + assert _rows(_refuse(fn, payload), model) == expected + + +def test_every_emitted_keyword_is_a_vocabulary_term() -> None: + """No invented value, graded against the vocabulary rather than a local list. + + ``validation`` was the invented one and is the regression this names: it is + not in ``Draft7Validator.VALIDATORS``, so it fails here without being + mentioned, and so would any other invention. + """ + emitted = { + keyword + for _, fn, payload, model, _ in CASES + for keyword, _ in _rows(_refuse(fn, payload), model) + } + assert emitted, "the case table produced no rows, so this asserts nothing" + outside = sorted(emitted - VOCABULARY) + assert not outside, f"these are not JSON Schema keywords: {outside}" + + +def test_a_compound_refusal_keeps_one_row_per_rule() -> None: + """``issues`` is an array, and a two-rule site must not collapse to one row. + + Stated separately from the table because this is the half no pre-existing + test could see, and the half most likely to regress: a single-row emitter + passes every message assertion in the suite. + """ + compound = [ + (label, fn, payload, model, expected) + for label, fn, payload, model, expected in CASES + if len(expected) > 1 + ] + assert len(compound) >= 2, "a compound site must stay in the table for this to grade anything" + for label, fn, payload, model, expected in compound: + rows = _rows(_refuse(fn, payload), model) + assert len(rows) == len(expected), f"{label}: expected {len(expected)} rows, got {rows}" + assert len({pointer for _, pointer in rows}) == len( + rows + ), f"{label}: two rows share a pointer, so one rule's location was lost: {rows}" diff --git a/tests/test_implementation_config.py b/tests/test_implementation_config.py index 0618482a7..b625f1d2e 100644 --- a/tests/test_implementation_config.py +++ b/tests/test_implementation_config.py @@ -42,26 +42,32 @@ def executor(): def _make_request(product_ids: list[str] | None): """Build a minimal CreateMediaBuyRequest with the given product ids. - Passing None simulates a proposal_id-driven request (no packages). + Passing None builds the committed-proposal request instead of the explicit- + packages one. Those are two of the three arms of + ``create-media-buy-request.json``, and the arm chosen decides which keys the + document may carry at all: the proposal arm forbids ``packages`` and the + packages arms forbid ``proposal_id``. So each is built by naming only its + own keys -- passing both with one set to ``None`` sends BOTH keys, which the + bundled schema refuses whichever value either carries. """ from adcp.types import CreateMediaBuyRequest - packages = None - if product_ids is not None: - packages = [ + payload: dict[str, Any] = { + "idempotency_key": "test-key-12345678", + "account": {"account_id": "acct_test"}, + "brand": {"domain": "example.com"}, + "start_time": "2026-06-01T00:00:00Z", + "end_time": "2026-07-01T00:00:00Z", + } + if product_ids is None: + payload["proposal_id"] = "prop-123" + payload["total_budget"] = {"amount": 1000.0, "currency": "USD"} + else: + payload["packages"] = [ {"product_id": pid, "budget": 1000.0, "pricing_option_id": "cpm_flat"} for pid in product_ids ] - - return CreateMediaBuyRequest( - idempotency_key="test-key-12345678", - account={"account_id": "acct_test"}, - brand={"domain": "example.com"}, - start_time="2026-06-01T00:00:00Z", - end_time="2026-07-01T00:00:00Z", - packages=packages, - proposal_id=None if product_ids is not None else "prop-123", - ) + return CreateMediaBuyRequest(**payload) def _make_handler( diff --git a/tests/test_import_layering.py b/tests/test_import_layering.py index 754533ec0..afe8e2cb6 100644 --- a/tests/test_import_layering.py +++ b/tests/test_import_layering.py @@ -73,11 +73,10 @@ # Add a file here only as a temporary measure; prefer fixing the import. # Remove a file here when its violation is fixed (the test will fail-closed # if you forget to update the list, which is the desired behavior). -_KNOWN_VIOLATIONS = frozenset( - { - "src/adcp/utils/preview_cache.py", - } -) +#: Empty: the last entry (``src/adcp/utils/preview_cache.py``) no longer imports +#: ``adcp.types._generated``, and the stale-entry half of the assertion below had +#: been failing for it. A baseline only shrinks. +_KNOWN_VIOLATIONS: frozenset[str] = frozenset() _FORBIDDEN_PREFIXES = ("adcp.types._generated",) diff --git a/tests/test_issue_keyword_vocabulary.py b/tests/test_issue_keyword_vocabulary.py new file mode 100644 index 000000000..3cd0d5afb --- /dev/null +++ b/tests/test_issue_keyword_vocabulary.py @@ -0,0 +1,342 @@ +"""``issues[].keyword`` is a JSON Schema keyword, on every refusal that ships one. + +``core/error.json`` asks for a keyword "drawn from the JSON Schema vocabulary … +Implementers SHOULD use the validator's native keyword name; do not invent +custom values here", and the pinned prose makes it a MUST -- +``building/by-layer/L3/error-handling.mdx``, Production-emit rules: "``keyword`` +MUST be drawn from the JSON Schema Draft 7 / 2020-12 vocabulary". The field is +REQUIRED on every entry, so there is no conformant option of leaving it out. + +The projection maps every pydantic type it recognises onto such a keyword and +falls back to ``validation`` for the two OPAQUE ones, ``value_error`` and +``assertion_error``. ``validation`` is not in the vocabulary, deliberately: the +raise site knows which schema construct it implements and the projection cannot, +so a blanket mapping would put a confident wrong keyword on the wire in place of +a recognisably wrong one. The fix for a failure here is therefore at the raise +site -- ``PydanticCustomError(<keyword>, …)`` -- never a widening of +:func:`adcp.validation.pydantic_errors._keyword`. + +Scope, stated because a green here is not a green for the whole tree: this +grades the SPLIT ROOTS, whose refusals are reachable with a one-key document. +80 ``raise ValueError(...)`` sites exist under ``src/adcp/types`` across 26 +modules, and any of them reaching ``issues[]`` is the same violation; this +population covers the one class measured reaching the wire +(salesagent-8qela.94). +""" + +from __future__ import annotations + +import ast +import pathlib +from typing import Any + +import pytest +from jsonschema import Draft7Validator +from pydantic import BaseModel, ValidationError + +import adcp.types +from adcp.validation.pydantic_errors import issues_from_validation_error + +# The one derivation of "every class that declares its branches", with the +# reasoning for why declaring the method is the selector. A second walk here +# would be a second answer to the same question. +from tests.test_branch_dispatch_on_every_path import _roots_with_branches + +# The one definition of a value a peer could send that must never come back, so +# this file and the echo suite grade the same string. +from tests.test_request_validation_no_input_echo import SECRET + +#: Taken from the validator that implements the vocabulary rather than restated, +#: so the set cannot drift from the thing the pin points at. +VOCABULARY = frozenset(Draft7Validator.VALIDATORS) + +SPLIT_ROOTS = sorted(_roots_with_branches().items()) + + +def test_split_roots_were_discovered() -> None: + """A population of nothing makes every assertion below vacuous.""" + assert len(SPLIT_ROOTS) >= 19, [name for name, _ in SPLIT_ROOTS] + + +def _documents(root: type[BaseModel]) -> list[dict[str, Any]]: + """The empty document, and one carrying each declared field alone. + + A branch forbids the fields its siblings require, so a single-key document + is the shortest thing that reaches a forbidden-name refusal -- which is the + refusal that was emitting a non-vocabulary keyword. Derived from the root's + own fields rather than authored per root. + """ + return [{}] + [{name: "x"} for name in root.model_fields] + + +@pytest.mark.parametrize(("name", "root"), SPLIT_ROOTS, ids=[n for n, _ in SPLIT_ROOTS]) +def test_every_keyword_a_split_root_emits_is_in_the_vocabulary( + name: str, root: type[BaseModel] +) -> None: + offenders: list[tuple[str, str]] = [] + for document in _documents(root): + try: + root.model_validate(document) + except ValidationError as error: + for issue in issues_from_validation_error(error): + if issue.keyword not in VOCABULARY: + offenders.append((issue.keyword, next(iter(document), "{}"))) + assert not offenders, f"{name} emitted keywords outside the JSON Schema vocabulary: {offenders}" + + +def test_the_population_actually_produces_keywords() -> None: + """Refusals reach the projection at all. + + Without this, a root that accepted every probe document would contribute no + issue, and the test above would pass by never looking at a keyword. Asserted + over the whole population rather than per root, since a root is allowed to + accept a one-key document. + """ + seen: set[str] = set() + for _name, root in SPLIT_ROOTS: + for document in _documents(root): + try: + root.model_validate(document) + except ValidationError as error: + seen.update(issue.keyword for issue in issues_from_validation_error(error)) + assert seen, "no split root refused any probe document, so no keyword was graded" + assert seen <= VOCABULARY, sorted(seen - VOCABULARY) + + +# --------------------------------------------------------------------------- +# The remainder, bounded rather than fixed. +# +# A bare ``raise ValueError`` reaches pydantic as ``value_error``, which has no +# JSON Schema equivalent, so any such raise that reaches ``issues[]`` carries the +# out-of-vocabulary fallback. The one site measured reaching the wire is fixed +# (``_conditions.absent``); the rest each need a per-site judgment about WHICH +# keyword they compile, and a confident wrong keyword on the wire is worse than a +# recognisably wrong one. So the count is frozen and allowed to shrink while +# those judgments are made. +# +# PER MODULE, not a total. A ceiling of 80 is satisfiable by adding a site to +# ``core.py`` and removing one from ``canonical_creative.py``, which is the one +# outcome a ratchet exists to prevent. +# +# To shrink an entry: give the raise site its keyword -- +# ``PydanticCustomError(<keyword>, ...)`` where the keyword names the schema +# construct the check compiles -- then lower the number here. The stale half of +# the assertion below fails if you forget, which is the desired behaviour. +# --------------------------------------------------------------------------- + +#: Paths relative to ``src/adcp/types``. Measured at 1eae4b862: 80 sites in 26 +#: modules, down from 81 in 27 before ``_conditions.absent`` was given its +#: keyword. +#: +#: Re-measured at 81 in 26 when this ratchet landed on main. ``core.py`` moved +#: 13 -> 14 because ``feat(client): cap agent response size by default`` (#1504) +#: added a bare raise validating ``max_response_bytes`` on a tree this ratchet +#: had never run against. That is a re-measurement of the merged tree rather +#: than a relaxation: the number this file records has to be the count on the +#: tree it guards, and taking the lower of the two sides would have silently +#: exempted a site nobody had looked at. The rule is unchanged and the entry is +#: still shrink-only from here. +BARE_VALUE_ERROR_BASELINE: dict[str, int] = { + "_geo_place_keys.py": 1, + "aliases.py": 1, + "canonical_creative.py": 11, + "canonical_decl.py": 2, + "core.py": 14, + "domains/adagents.py": 3, + "domains/bundled/protocol/get_adcp_capabilities_response.py": 3, + "domains/core/creative_representation.py": 1, + "domains/core/placement_presentation.py": 1, + "domains/core/postal_area.py": 1, + "domains/core/presentation_ref.py": 1, + "domains/core/preview_provider.py": 1, + "domains/core/reference_renderer.py": 2, + "domains/core/reporting_delivery_capabilities.py": 3, + "domains/core/reporting_delivery_config.py": 4, + "domains/core/transformer.py": 1, + "domains/media_buy/change_term.py": 1, + "domains/media_buy/change_term_constraints.py": 4, + "domains/media_buy/commercial_terms.py": 7, + "domains/media_buy/get_media_buy_delivery_request.py": 2, + "domains/media_buy/legacy_purchase_continuation_input.py": 3, + "domains/trusted_match/identity_match_response.py": 1, + "domains/trusted_match/provider_registration.py": 6, + "projections.py": 2, + "versioned.py": 4, + "versioned_bases/_runtime.py": 1, +} + +_TYPES_ROOT = pathlib.Path(adcp.types.__file__).parent + + +def _bare_value_error_counts() -> dict[str, int]: + """``raise ValueError(...)`` per module under ``adcp.types``, by parsing. + + One derivation, module-scoped. Walking each FUNCTION and calling + ``ast.walk`` on it instead double-counts every nested raise, because + ``ast.walk`` on an outer function also reaches the bodies of functions + defined inside it -- which is how this count was first reported as 83. + """ + counts: dict[str, int] = {} + for path in sorted(_TYPES_ROOT.rglob("*.py")): + if "__pycache__" in path.parts: + continue + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError: # pragma: no cover - shipped modules parse + continue + found = sum( + 1 + for node in ast.walk(tree) + if isinstance(node, ast.Raise) + and isinstance(node.exc, ast.Call) + and getattr(node.exc.func, "id", getattr(node.exc.func, "attr", "")) == "ValueError" + ) + if found: + counts[path.relative_to(_TYPES_ROOT).as_posix()] = found + return counts + + +def test_no_module_grows_its_bare_value_error_count() -> None: + """The remainder only shrinks, and it shrinks where it is recorded.""" + counts = _bare_value_error_counts() + messages: list[str] = [] + + for module, found in sorted(counts.items()): + allowed = BARE_VALUE_ERROR_BASELINE.get(module) + if allowed is None: + messages.append( + f" NEW module with a bare ValueError: {module} ({found}). " + "Raise PydanticCustomError(<keyword>, ...) naming the schema " + "construct the check compiles." + ) + elif found > allowed: + messages.append(f" {module}: {found} raises, baseline {allowed}") + + # The stale half. Without it a fixed site leaves its number behind, the + # ceiling drifts upward of reality, and a later regression lands under it + # unnoticed. + for module, allowed in sorted(BARE_VALUE_ERROR_BASELINE.items()): + found = counts.get(module, 0) + if found < allowed: + messages.append( + f" STALE: {module} now has {found} (baseline {allowed}) — " + "lower it in BARE_VALUE_ERROR_BASELINE." + ) + + if messages: + raise AssertionError( + "src/adcp/types bare-ValueError ratchet (salesagent-8qela.94):\n" + "\n".join(messages) + ) + + +def test_the_baseline_is_a_distribution_not_a_total() -> None: + """The pin is per module, which is what a bare total cannot do. + + A single ceiling is satisfied by moving a violation between modules. This + asserts the shape the condition requires: more than one module, and the + recorded total agreeing with the sum, so neither can be edited alone. + """ + assert len(BARE_VALUE_ERROR_BASELINE) > 1 + assert sum(BARE_VALUE_ERROR_BASELINE.values()) == 81, ( + "the recorded distribution no longer sums to the measured baseline; " + "shrink an entry rather than adjusting this number" + ) + + +# --------------------------------------------------------------------------- +# A composition refusal must name the fields, not just the keyword. +# +# ``allOf composition failed`` tells a buyer which RULE fired and nothing about +# where to look. The field names are not user data -- they come from the pinned +# schema, which is why ``safe_message_for`` has always passed a ``required`` +# failure's schema-declared key name through. The same reasoning covers a +# composition's fields. +# +# The compiled validators compose those names from the schema and put them in +# the error's context (``name_the_combinator``'s groups and fields, +# ``absent``'s forbidden names). Until now the projection dropped the context +# and rendered the keyword alone. +# --------------------------------------------------------------------------- + +#: Documents whose refusal is a composition WITH field names in the context. +#: Derived by running the model, not authored: each entry is kept only if the +#: raw error actually carries ``ctx["fields"]``, so the parametrize cannot grade +#: a case whose context is empty. +#: +#: Each carries the secret IN A VALUE, so the same cases that grade "the message +#: names the schema's fields" also grade "and nothing of the instance". Naming +#: more makes leaking more possible, so the two assertions belong on one case. +_COMPOSITION_PROBES = [ + ("empty document", {}), + ("a forbidden name", {"packages": 1, "name": SECRET}), + ("another forbidden name", {"proposal_id": SECRET}), +] + + +def _composition_cases() -> list[tuple[str, str, str]]: + """(label, the ctx fields, the wire message) for each probe that qualifies. + + The composition refusal is SEARCHED FOR rather than read off position zero. + A refusal can carry several errors and the composition is not first: a + document omitting fields the SCHEMA ROOT requires is refused with those + misses located, so that a consumer reading ``issues[0]`` gets a pointer, + with the composition beside them (salesagent-8qela.116). Reading + ``errors()[0]`` made this population empty and the rule below ungraded, + while every probe still produced the refusal it is about. + """ + from adcp.types import CreateMediaBuyRequest + + composition_keywords = {"oneOf", "anyOf", "allOf", "not"} + found: list[tuple[str, str, str]] = [] + for label, payload in _COMPOSITION_PROBES: + try: + CreateMediaBuyRequest.model_validate(payload) + except ValidationError as error: + # Positionally paired: ``issues_from_validation_error`` projects one + # issue per pydantic error in order, so index i of each is the same + # refusal in two representations -- the raw context and the wire + # message. Measured 6:6 on each probe here. + for raw, issue in zip(error.errors(), issues_from_validation_error(error), strict=True): + fields = (raw.get("ctx") or {}).get("fields") + if fields and issue.keyword in composition_keywords: + found.append((label, str(fields), issue.message)) + break + return found + + +COMPOSITION_CASES = _composition_cases() + + +def test_a_composition_refusal_carrying_fields_exists() -> None: + """Without one, the test below grades nothing. + + A probe whose context is empty is skipped above rather than asserted + against, because an empty context is a separate defect in the generator + (salesagent-8qela.49) and not something this check can see. + """ + assert COMPOSITION_CASES, ( + "no probe produced a composition refusal carrying ctx['fields'], so the " + "message-names-its-fields rule is ungraded" + ) + + +@pytest.mark.parametrize( + ("label", "fields", "message"), COMPOSITION_CASES, ids=[c[0] for c in COMPOSITION_CASES] +) +def test_a_composition_message_names_the_schemas_fields( + label: str, fields: str, message: str +) -> None: + """Every field name the error cites reaches the wire message. + + Schema-derived throughout: the generator composes these names from the + schema node, so carrying them is a citation rather than an echo. Nothing + from the instance travels -- the value-free property is graded by + ``tests/test_request_validation_no_input_echo.py``. + """ + for name in (part.strip() for part in fields.split(",")): + assert ( + name and name in message + ), f"{label}: the refusal cites {name!r} and the wire message does not: {message!r}" + # The other half, on the same case: naming the schema's fields must not + # become naming the peer's values. Each probe above puts SECRET in a value. + assert SECRET not in message, f"{label}: the message echoed a payload value: {message!r}" diff --git a/tests/test_lazy_types.py b/tests/test_lazy_types.py index fb9913ed3..94d4302fb 100644 --- a/tests/test_lazy_types.py +++ b/tests/test_lazy_types.py @@ -20,6 +20,7 @@ import ast import subprocess import sys +from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest @@ -106,6 +107,13 @@ def test_version_not_clobbered_by_version_submodule() -> None: # adcp`` used to mask import-order cycles by loading everything in a fixed # order; under the lazy facade each must import standalone. Guards the # webhooks <-> webhook_sender cycle (and any sibling) from regressing. +# +# Named one by one because they are top-level modules with no shared shape to +# derive from. The ``adcp.types`` package tree below IS derived, and it was +# added after this list missed a cycle entirely: an eager domain facade made all +# 16 modules under ``formats/canonical/``, ``content_standards`` entire, +# ``core.product`` and four top-level modules unimportable on their own, and +# nothing here covered any of them. _STANDALONE_FIRST_IMPORT = [ "adcp.webhook_sender", "adcp.webhooks", @@ -120,21 +128,103 @@ def test_version_not_clobbered_by_version_submodule() -> None: "adcp.server", ] +#: Modules whose import is MEANT to fail, carrying a refusal as their content. A +#: removal shim raises ``ImportError`` naming the migration, so a standalone +#: import check has to tell that apart from a defect. +_DELIBERATE_IMPORT_REFUSALS = frozenset({"adcp.types.domains.enums.format_category"}) + + +def _module_name(path: Path) -> str: + parts = path.resolve().relative_to(SRC_TYPES.parent.parent).with_suffix("").parts + return ".".join(parts).removesuffix(".__init__") + + +def _types_tree_population() -> list[str]: + """Every package root under ``adcp.types``, plus leaves: ALL at the root, one deeper. + + DERIVED, so a package added later is covered without anyone remembering. + + ONE leaf per DEEP package rather than all 1139 modules, and that is a + reduction with a reason rather than a sample: the amplifier there is a + package ``__init__``, so when one runs eagerly and its domain is in a cycle, + EVERY leaf of that package fails and one of them detects it. The full tree is + about an hour of CPU here -- a subprocess import costs ~3s and 1057 of the + 1139 modules import across package boundaries, so there is no cheaper honest + subset -- which is a scan to run by hand when the import graph is reshaped, + not a guard. + + ALL leaves of the ``adcp.types`` ROOT, because that argument does not hold + there and sampling one of them hid a live defect for as long as this + population existed. The root's 27 top-level modules are not leaves of a + generated domain that fails together; each is hand-written and cross-imports + on its own terms. The sampled one was ``aliases`` (alphabetically first) and + the broken one was ``projections``, which imported four TYPE NAMES from + ``adcp.types`` -- routed through the single ``_eager`` module that binds the + whole surface and that imports ``projections`` back, so the module could not + be imported first. This test was green throughout (salesagent-8qela.77). + 26 extra subprocess imports, pooled, ~10s. + """ + population: set[str] = set() + for init in sorted(SRC_TYPES.rglob("__init__.py")): + if "__pycache__" in init.parts: + continue + population.add(_module_name(init)) + leaves = sorted( + path + for path in init.parent.glob("*.py") + if path.name != "__init__.py" and not path.name.startswith("_") + ) + if leaves: + population.update( + _module_name(leaf) for leaf in (leaves if init.parent == SRC_TYPES else leaves[:1]) + ) + population -= _DELIBERATE_IMPORT_REFUSALS + assert len(population) > 30, f"population is {len(population)}: that is not the package tree" + # The package's own entry point, named rather than left to the derivation. + # Removing an edge a cycle runs through can trade a direct-import failure for + # a worse top-level one, and a population that stopped covering the root + # would let that pass. + assert "adcp.types" in population, "the population lost the package entry point" + assert "adcp.types.projections" in population, ( + "the population lost adcp.types.projections, whose import-first failure " + "this sampling could not see before (salesagent-8qela.60 / .77)" + ) + return sorted(population) -@pytest.mark.parametrize("module", _STANDALONE_FIRST_IMPORT) -def test_submodule_imports_standalone(module: str) -> None: - """Each cross-importing submodule must import first in a fresh interpreter. + +def test_submodule_imports_standalone() -> None: + """Each cross-importing module must import FIRST, in a fresh interpreter. A circular import only manifests when the module is imported *before* the - module it cycles with, so each runs in its own subprocess. + module it cycles with, so a fresh interpreter per module is the measurement + rather than an implementation detail. + + One test over a pool rather than one test per module: the cost here is + entirely the subprocesses, and pytest parameters would serialize what is + embarrassingly parallel. The failure message names every module that could + not be imported first, with its traceback. """ - result = subprocess.run( - [sys.executable, "-c", f"import {module}"], - capture_output=True, - text=True, - check=False, + population = sorted({*_STANDALONE_FIRST_IMPORT, *_types_tree_population()}) + + def probe(module: str) -> tuple[str, str]: + result = subprocess.run( + [sys.executable, "-c", f"import {module}"], + capture_output=True, + text=True, + check=False, + ) + return module, "" if result.returncode == 0 else result.stderr.strip()[-400:] + + with ThreadPoolExecutor(max_workers=8) as pool: + outcomes = dict(pool.map(probe, population)) + + assert len(outcomes) == len( + population + ), f"{len(outcomes)} results for a population of {len(population)}" + failures = {module: why for module, why in outcomes.items() if why} + assert not failures, "modules that cannot be imported first:\n" + "\n\n".join( + f"{module}\n{why}" for module, why in sorted(failures.items()) ) - assert result.returncode == 0, f"`import {module}` failed standalone:\n{result.stderr}" def test_import_adcp_types_does_not_build_schema_graph() -> None: diff --git a/tests/test_legacy_surface_names_every_arm.py b/tests/test_legacy_surface_names_every_branch.py similarity index 85% rename from tests/test_legacy_surface_names_every_arm.py rename to tests/test_legacy_surface_names_every_branch.py index c710cf0c1..57a337f02 100644 --- a/tests/test_legacy_surface_names_every_arm.py +++ b/tests/test_legacy_surface_names_every_branch.py @@ -1,4 +1,4 @@ -"""A ``Legacy*`` name is the generated class, and there is one per response arm. +"""A ``Legacy*`` name is the generated class, and there is one per response branch. Two defects, one cause: the ``Legacy*`` set was curated by hand, so what it named drifted from what the generator emits. @@ -13,16 +13,16 @@ #1399 — ``LegacyUpdateMediaBuy{Success,Error}Response`` shipped while ``LegacyUpdateMediaBuySubmittedResponse`` and all three -``LegacyCreateMediaBuy*Response`` arms did not, so four response arms could only +``LegacyCreateMediaBuy*Response`` branches did not, so four response branches could only be named through ``adcp.types.domains.media_buy.*_response`` — the private tree this release exists to retire. Adding four names would have fixed four instances: the generator had ALREADY opened a fifth, by adding a fourth -``preview_creative`` arm that nobody named. +``preview_creative`` branch that nobody named. So neither test reads a list. Both derive what the surface must carry from the generated tree: -* the arms of a response union come from ``typing.get_args`` of the union the +* the branches of a response union come from ``typing.get_args`` of the union the generated module itself declares — not from a name pattern, and not from ``__module__`` (which names the canonical spec even for a duplicate, and so cannot tell a copy from the original); @@ -64,16 +64,16 @@ def _camel(stem: str) -> str: return "".join(part.title() for part in stem.split("_")) -def _response_arms() -> list[tuple[str, str, type[BaseModel]]]: - """Every arm of every response union whose tool is on the ``Legacy*`` surface. +def _response_branches() -> list[tuple[str, str, type[BaseModel]]]: + """Every branch of every response union whose tool is on the ``Legacy*`` surface. - ``(tool, arm name, arm class)``. The arms come from the generated module's - own ``<Tool>Response`` union, so a schema that gains or loses an arm changes + ``(tool, branch name, branch class)``. The branches come from the generated module's + own ``<Tool>Response`` union, so a schema that gains or loses an branch changes this list with no edit here. A response that is one class rather than a - union contributes that class as its only arm. + union contributes that class as its only branch. """ - arms: list[tuple[str, str, type[BaseModel]]] = [] + branches: list[tuple[str, str, type[BaseModel]]] = [] for path in sorted(_DOMAINS_DIR.rglob("*_response.py")): relative = path.relative_to(_DOMAINS_DIR) if relative.parts[0] == "bundled": @@ -92,12 +92,12 @@ def _response_arms() -> list[tuple[str, str, type[BaseModel]]]: members = typing.get_args(union) or (union,) for member in members: if isinstance(member, type) and issubclass(member, BaseModel): - arms.append((tool, member.__name__, member)) - return arms + branches.append((tool, member.__name__, member)) + return branches -ARMS = _response_arms() -ARM_IDS = [f"{tool}:{name}" for tool, name, _ in ARMS] +BRANCHES = _response_branches() +BRANCH_IDS = [f"{tool}:{name}" for tool, name, _ in BRANCHES] def test_the_derived_sets_are_not_empty() -> None: @@ -108,13 +108,13 @@ def test_the_derived_sets_are_not_empty() -> None: """ assert len(LEGACY_NAMES) > 40, LEGACY_NAMES assert len(LEGACY_CLASSES) > 35, LEGACY_CLASSES - assert len(ARMS) > 12, ARM_IDS - assert {tool for tool, _, _ in ARMS} >= { + assert len(BRANCHES) > 12, BRANCH_IDS + assert {tool for tool, _, _ in BRANCHES} >= { "BuildCreative", "CreateMediaBuy", "PreviewCreative", "UpdateMediaBuy", - }, sorted({tool for tool, _, _ in ARMS}) + }, sorted({tool for tool, _, _ in BRANCHES}) # --------------------------------------------------------------------------- @@ -207,29 +207,29 @@ def test_the_format_reference_accepts_what_the_sdk_s_own_model_fields_accept() - # --------------------------------------------------------------------------- -# #1399 — one Legacy name per arm, derived from the generated unions +# #1399 — one Legacy name per branch, derived from the generated unions # --------------------------------------------------------------------------- -@pytest.mark.parametrize(("tool", "arm_name", "arm"), ARMS, ids=ARM_IDS) -def test_every_arm_of_a_response_on_the_legacy_surface_is_publicly_nameable( - tool: str, arm_name: str, arm: type[BaseModel] +@pytest.mark.parametrize(("tool", "branch_name", "branch"), BRANCHES, ids=BRANCH_IDS) +def test_every_branch_of_a_response_on_the_legacy_surface_is_publicly_nameable( + tool: str, branch_name: str, branch: type[BaseModel] ) -> None: - """The generated arm itself, not only the canonical subclass that shadows it. + """The generated branch itself, not only the canonical subclass that shadows it. A seller reading a response off the wire holds an instance of the GENERATED - arm. ``adcp.types.CreateMediaBuySuccessResponse`` is the canonical subclass, + branch. ``adcp.types.CreateMediaBuySuccessResponse`` is the canonical subclass, so an ``isinstance`` against it is False for that instance and the only public spelling for the thing in hand was a ``domains`` path. The - ``Legacy*`` name is how the flat surface reaches it, and every arm of a + ``Legacy*`` name is how the flat surface reaches it, and every branch of a response whose tool is on that surface needs one. """ - direct = [name for name in PLAIN_NAMES if getattr(types, name, None) is arm] - through_legacy = [name for name in LEGACY_NAMES if getattr(types, name, None) is arm] + direct = [name for name in PLAIN_NAMES if getattr(types, name, None) is branch] + through_legacy = [name for name in LEGACY_NAMES if getattr(types, name, None) is branch] assert direct or through_legacy, ( - f"{arm.__module__}.{arm_name} is an arm of {tool}Response and no public " + f"{branch.__module__}.{branch_name} is a branch of {tool}Response and no public " f"name on adcp.types resolves to it, so it can only be named through " f"adcp.types.domains — which is the tree 9.0 retires. Add " - f"Legacy{tool}<Arm>Response in src/adcp/types/legacy.py (#1399)" + f"Legacy{tool}<Branch>Response in src/adcp/types/legacy.py (#1399)" ) diff --git a/tests/test_media_buy_store_integration.py b/tests/test_media_buy_store_integration.py index 2c19d9c57..ba44580ec 100644 --- a/tests/test_media_buy_store_integration.py +++ b/tests/test_media_buy_store_integration.py @@ -225,7 +225,14 @@ async def create_media_buy(self, req, ctx): idempotency_key="idem_aaaa1234567890", start_time="2026-05-01T00:00:00Z", end_time="2026-05-31T23:59:59Z", - packages=[{"buyer_ref": "pkg_1", "product_id": "prod_1", "pricing_option_id": "cpm"}], + packages=[ + { + "buyer_ref": "pkg_1", + "product_id": "prod_1", + "pricing_option_id": "cpm", + "budget": 1000.0, + } + ], ), ToolContext(), ) diff --git a/tests/test_negotiated_catalog_purchase.py b/tests/test_negotiated_catalog_purchase.py index 62765b307..97a9801ca 100644 --- a/tests/test_negotiated_catalog_purchase.py +++ b/tests/test_negotiated_catalog_purchase.py @@ -27,7 +27,9 @@ ListProductsRequest, ) from adcp.types.core import TaskResult, TaskStatus -from adcp.types.legacy import LegacyGetProductsResponse +from adcp.types.versioned_bases.v30 import ( + GetProductsResponseBase as GetProductsResponseBaseV30, +) _VECTORS = ( Path(__file__).parent @@ -410,7 +412,13 @@ def test_completed_payload_does_not_invent_newer_response_defaults() -> None: tools = ["get_products", "create_media_buy"] coordinator = MediaBuyLifecycleCoordinator(client, _caps(["3.0"], tools=tools), tools) wire = copy.deepcopy(_CASES[1]["legacy_response"]) - typed = LegacyGetProductsResponse.model_validate(wire) + # Validated through the 3.0 surface, because that is the version this + # coordinator negotiated and therefore the schema that governs the + # response. ``LegacyGetProductsResponse`` is an alias for the CURRENT + # generated class, so it carries 3.2's root conditional -- which requires + # ``cache_scope`` on every 3.1+ response and so refuses the very shape this + # test is about: a seller that sent none of the newer fields. + typed = GetProductsResponseBaseV30.model_validate(wire) payload = coordinator._completed_payload( "list_products", diff --git a/tests/test_no_import_time_field_patching.py b/tests/test_no_import_time_field_patching.py new file mode 100644 index 000000000..b839bb808 --- /dev/null +++ b/tests/test_no_import_time_field_patching.py @@ -0,0 +1,111 @@ +"""No module reaches into a built model to change what one of its fields accepts. + +Two mechanisms used to do this, and both are gone: + +* ``_ergonomic.py`` wrote ``model.__annotations__[field] = Annotated[...]`` and + called ``model_rebuild(force=True)``. 70 of its 74 patch calls were INERT -- + ``model_rebuild`` rebuilds the schema from the existing ``model_fields``, not + from ``__annotations__``, so the ``BeforeValidator`` never reached it. The + coercion appeared to work because pydantic already did it: a generated enum is + a ``StrEnum``, so a string is accepted natively, and a model field is built + from a dict natively. Measured: with ``_apply_coercion()`` disabled, the full + validation surface of all 14 models it named was byte-identical except the four + fields patched by the OTHER mechanism (salesagent-1jj5e). +* ``_forward_compat.py`` wrote ``model.model_fields[field]`` and rebuilt. That + one did take effect -- and reached only the class it named, which is how a + parsed ``create_media_buy`` request ended up with an un-bridged + ``targeting_overlay`` and how a hand-written subclass kept demanding a bundled + clone its parent had stopped demanding (salesagent-wftrb). + +Both failure modes have the same cause: a model's accepted shape was decided +somewhere other than where the field is declared. So this grades the rule rather +than either instance -- a new module doing it in a third spelling fails here. + +The declaration site is the generator's: ``PIN_GAP_FIELDS`` in +``scripts/generate_types.py``, and ``COMPATIBILITY_ANNOTATIONS`` / +``READBACK_CLONES`` / ``_OPEN_ASSET_UNIONS`` in +``scripts/post_generate_fixes.py``. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +SRC_ROOT = Path(__file__).parent.parent / "src" / "adcp" + +#: ``aliases.py`` attaches the publisher-selector XOR validator to two generated +#: classes and forces a rebuild (salesagent-z1zqn). It is the last one, it is a +#: validator rather than a field annotation, and it is tracked rather than +#: tolerated: this set only shrinks. +_KNOWN_PATCHERS = frozenset({"src/adcp/types/aliases.py"}) + + +def _patch_sites(tree: ast.Module) -> list[str]: + """Writes into a built model's field machinery, in any spelling. + + Four shapes. The two deleted mechanisms used two of them, the third is the + nearest variant of those, and the fourth is the one spelling still in the + repo -- which is how this grading was found to be incomplete: the allowlist + entry below did not match the first three, and + ``test_every_known_patcher_still_patches`` said so. + + * ``X.__annotations__[...] = ...`` -- what ``_ergonomic`` did + * ``X.model_fields[...] = ...`` -- what ``_forward_compat`` did + * ``X.model_fields[...].metadata.append(...)`` / ``.extend(...)`` + * ``X.__pydantic_decorators__.<kind>[...] = ...`` -- a validator, rather + than a field, grafted onto a class from outside it + """ + found: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if not isinstance(target, ast.Subscript): + continue + value = target.value + if isinstance(value, ast.Attribute) and value.attr in { + "__annotations__", + "model_fields", + }: + found.append(f"line {node.lineno}: {ast.unparse(target)[:60]} = ...") + elif "__pydantic_decorators__" in ast.unparse(value): + found.append(f"line {node.lineno}: {ast.unparse(target)[:60]} = ...") + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if node.func.attr not in {"append", "extend"}: + continue + owner = node.func.value + if not (isinstance(owner, ast.Attribute) and owner.attr == "metadata"): + continue + if "model_fields" in ast.unparse(owner): + found.append(f"line {node.lineno}: {ast.unparse(node)[:60]}") + return found + + +def test_no_module_patches_a_built_model_field() -> None: + """Derived over the whole package, so a new patcher in a new module fails.""" + repo_root = SRC_ROOT.parent.parent + scanned = 0 + violators: dict[str, list[str]] = {} + for path in sorted(SRC_ROOT.rglob("*.py")): + scanned += 1 + rel = path.relative_to(repo_root).as_posix() + sites = _patch_sites(ast.parse(path.read_text(encoding="utf-8"))) + if sites and rel not in _KNOWN_PATCHERS: + violators[rel] = sites + # A derivation that matches nothing is green, so the population is asserted. + assert scanned > 1000, f"the walk found only {scanned} modules under {SRC_ROOT}" + assert not violators, ( + "these modules change what a built model's field accepts, instead of " + f"declaring it where the field is declared: {violators}" + ) + + +def test_every_known_patcher_still_patches() -> None: + """A fixed entry has to be removed, so the set cannot outlive its reason.""" + repo_root = SRC_ROOT.parent.parent + stale = [ + rel + for rel in sorted(_KNOWN_PATCHERS) + if not _patch_sites(ast.parse((repo_root / rel).read_text(encoding="utf-8"))) + ] + assert not stale, f"no longer patches, remove from _KNOWN_PATCHERS: {stale}" diff --git a/tests/test_observability.py b/tests/test_observability.py index a659a64a4..de85de170 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -69,7 +69,7 @@ async def completed(_params: dict[str, Any]) -> TaskResult[Any]: return TaskResult( status=TaskStatus.COMPLETED, success=True, - data={"products": []}, + data={"products": [], "cache_scope": "public"}, ) with patch.object(client.adapter, "get_products", new=completed): @@ -130,7 +130,7 @@ async def completed(_params: dict[str, Any]) -> TaskResult[Any]: return TaskResult( status=TaskStatus.COMPLETED, success=True, - data={"products": []}, + data={"products": [], "cache_scope": "public"}, ) with patch.object(client.adapter, "get_products", new=completed): diff --git a/tests/test_open_format_kind_vocabulary.py b/tests/test_open_format_kind_vocabulary.py index 73b1cc6a9..aecd94e1c 100644 --- a/tests/test_open_format_kind_vocabulary.py +++ b/tests/test_open_format_kind_vocabulary.py @@ -31,6 +31,7 @@ import json import pathlib +import re import sys import pytest @@ -78,7 +79,16 @@ def test_no_generated_model_types_a_field_with_the_closed_enum() -> None: for path in sorted(_GENERATED.rglob("*.py")): for number, line in enumerate(path.read_text().splitlines(), 1): stripped = line.strip() - if "CanonicalFormatKind" not in stripped: + # The IDENTIFIER, not the substring. A longer generated name that + # merely ENDS in it re-closes nothing: branch-class names are built + # from their branch's discriminating condition, so + # ``CreativeAssetV2CreativeCanonicalFormatKind`` -- the oneOf/1 + # branch of core/creative-asset.json -- contains these 18 characters + # and annotates no field with the enum. A substring test reported all + # seven of its occurrences as re-closures while the real count was + # zero, which is a guard grading a spelling rather than the property + # this docstring states. + if not re.search(r"\bCanonicalFormatKind\b", stripped): continue if ( stripped.startswith(("import ", "from ", "#", '"')) diff --git a/tests/test_preview_html.py b/tests/test_preview_html.py index 165a1a956..b915e48d1 100644 --- a/tests/test_preview_html.py +++ b/tests/test_preview_html.py @@ -16,9 +16,11 @@ Product, Protocol, ) -from adcp.types._generated import ( - CreativeManifest, -) + +# Every construction below passes ``format_id``, which names the deprecated +# named-format arm. Taken from the public surface rather than ``_generated``: +# the root's public name is a union now, and the arm is what these build. +from adcp.types import CreativeManifestNamedFormatManifest as CreativeManifest from adcp.types.core import TaskResult, TaskStatus from adcp.types.legacy import ( LegacyFormat, @@ -441,12 +443,14 @@ async def test_get_products_with_preview_urls(): # Raw result from adapter (unparsed) mock_raw_result = TaskResult( status=TaskStatus.COMPLETED, - data={"products": []}, # Will be replaced by _parse_response mock + data={"products": [], "cache_scope": "public"}, # Replaced by the _parse_response mock success=True, ) # Parsed result from _parse_response - mock_products_response = GetProductsResponse(products=[product], errors=None) + mock_products_response = GetProductsResponse( + products=[product], errors=None, cache_scope="public" + ) mock_parsed_result = TaskResult( status=TaskStatus.COMPLETED, data=mock_products_response, success=True ) diff --git a/tests/test_principal.py b/tests/test_principal.py index 6d08ab6d1..b0b6d7299 100644 --- a/tests/test_principal.py +++ b/tests/test_principal.py @@ -177,7 +177,11 @@ async def test_wait_for_destinations_rejects_missing_readback() -> None: "principal_id": "principal-1", "principal_kind": "buyer_agent", "configuration_version": "version-7", - "configuration": {}, + # core/principal-state.json states minProperties 1, so ``{}`` is + # a document the spec refuses -- it was accepted only while that + # keyword was uncompiled. The member has to be one that is NOT + # reporting_destinations, which is the absence this test asserts. + "configuration": {"notification_configs": []}, } } ) @@ -210,12 +214,23 @@ async def test_wait_for_destinations_rejects_a_replaced_generation() -> None: @pytest.mark.asyncio async def test_payload_failure_is_not_treated_as_success() -> None: + # ``status`` is supplied because the document would otherwise be one no + # conformant server sends. ``protocol/sync-principal-response.json``'s root + # states ``if result.kind == "failed" then status in ["failed","rejected"]``, + # and ``core/protocol-envelope.json`` makes ``status`` REQUIRED with no + # default -- so a failed result cannot omit it and cannot carry + # ``completed``. The generated envelope defaults it to ``completed`` for + # ergonomic construction, which is why omitting it here built an invalid + # document; ``rejected`` is what the SDK itself sends for a failed result + # (``server/principal.py`` lines 187 and 403, measured: every failed + # response it emits supplies one of the two legal values). failed = SyncPrincipalResponse.model_validate( { + "status": "rejected", "result": { "kind": "failed", "errors": [{"code": "CONFLICT", "message": "stale configuration"}], - } + }, } ) diff --git a/tests/test_principal_rejection_names_no_internals.py b/tests/test_principal_rejection_names_no_internals.py new file mode 100644 index 000000000..6b8d937ef --- /dev/null +++ b/tests/test_principal_rejection_names_no_internals.py @@ -0,0 +1,136 @@ +"""``sync_principal``'s rejection names the buyer's field, not our classes. + +``core/error.json`` is what ``result.errors[]`` items are (``$ref`` to +``core/error.json``), so the string lands in ``error.message``. The MUST-NOT on +producer-internal context sits on ``buyer_reason.message``, not here — but the +spec never asks for a class name in ``message`` either, and a path that names a +generated union branch does not resolve against what the peer sent. + +Two leaks, both measured on this path before the fix: pydantic's ``msg`` for a +type mismatch reads "instance of Configuration", and its ``loc`` carries the +selected union branch's class name (``AgentReportingDestination1``) for any +union, with no wrapping validator involved. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from adcp.server.principal import _first_message +from adcp.types import SyncPrincipalRequest + +#: Long enough to satisfy the field's own minLength, so a probe reaches the +#: content validators instead of stopping on idempotency_key. +IDEM = "idem-0123456789abcdef" + +#: Class names reachable on this path. Each is a generated model or a pydantic +#: wrapper spelling; none is a field a buyer could act on. +INTERNAL_NAMES = ( + "AgentReportingDestination", + "AgentNotificationConfig", + "AgentDeclarations", + "Configuration", + "SyncPrincipalRequest", + "ExtensionObject", + "function-after", +) + +#: Each rejects, and each reaches a CONTENT validator rather than requiredness +#: on idempotency_key — verified by ``test_every_payload_reaches_a_rejection``. +REJECTED_PAYLOADS = [ + pytest.param({"idempotency_key": IDEM, "configuration": "x"}, id="configuration-not-object"), + pytest.param( + {"idempotency_key": IDEM, "configuration": {"declarations": "x"}}, + id="declarations-not-object", + ), + pytest.param( + {"idempotency_key": IDEM, "configuration": {"reporting_destinations": [{}]}}, + id="union-branch-missing-field", + ), + pytest.param( + {"idempotency_key": IDEM, "configuration": {"reporting_destinations": [{"kind": "x"}]}}, + id="union-branch-unknown-shape", + ), + pytest.param( + {"idempotency_key": IDEM, "configuration": {"notification_configs": [{}]}}, + id="notification-config-missing-field", + ), + pytest.param( + {"idempotency_key": IDEM, "configuration": {"declarations": {"unknown_key": "v"}}}, + id="extra-property", + ), + pytest.param("not-an-object", id="scalar-payload"), +] + + +def _message_for(payload: object) -> str: + try: + SyncPrincipalRequest.model_validate(payload) + except ValidationError as error: + return _first_message(error, payload) + raise AssertionError("payload validated when the test needs it rejected") + + +@pytest.mark.parametrize("payload", REJECTED_PAYLOADS) +def test_rejection_names_no_internal_class(payload): + """Asks: does a producer-internal name reach the peer? Nothing more. + + It does NOT establish that the message is still ACTIONABLE. Those are + adjacent questions, and answering this one was read as answering both: + the projection that removed these names also removed the rule name a + conjunct failure carries, and only the upstream delta caught it + (salesagent-8qela.34). A green result here means no leak, not no loss. + """ + message = _message_for(payload) + for name in INTERNAL_NAMES: + assert name not in message, f"{name!r} reached the peer: {message}" + + +@pytest.mark.parametrize("payload", REJECTED_PAYLOADS) +def test_rejection_path_resolves_against_the_payload(payload): + """Every path segment must be a key or index the peer actually sent. + + A union branch name is a plain string indistinguishable from a key, so + this walks the document instead of matching spellings. The terminal + segment is exempt: a required-field failure names a key that is absent by + definition. + """ + message = _message_for(payload) + location = message.split(":", 1)[0] if ":" in message else "" + if not location: + return + + node = payload + segments = [s for s in location.replace("[", ".").replace("]", "").split(".") if s] + for segment in segments[:-1]: + if isinstance(node, dict): + assert segment in node, f"{segment!r} is not a key the peer sent: {location}" + node = node[segment] + elif isinstance(node, list): + assert segment.isdigit() and int(segment) < len(node), location + node = node[int(segment)] + else: + raise AssertionError(f"{location} descends into a {type(node).__name__}") + + +def test_every_payload_reaches_a_rejection(): + """Guards against a probe that never gets past requiredness. + + Four earlier iterations of this measurement were stopped by + ``idempotency_key`` requiredness, then its minLength, then + ``subscriber_id``, then ``event_types`` — each time reporting a clean zero + for a path it had not reached. + """ + messages = [_message_for(p.values[0]) for p in REJECTED_PAYLOADS] + assert len(messages) == len(REJECTED_PAYLOADS) + assert not [m for m in messages if m.startswith("idempotency_key")], messages + + +def test_the_location_is_jsonpath_lite_not_a_pointer(): + """``error.field``'s format, so the message agrees with it.""" + message = _message_for( + {"idempotency_key": IDEM, "configuration": {"reporting_destinations": [{}]}} + ) + assert message.startswith("configuration.reporting_destinations[0]."), message + assert "/" not in message.split(":", 1)[0] diff --git a/tests/test_product_format_declaration.py b/tests/test_product_format_declaration.py index 7ff505a99..da11943c2 100644 --- a/tests/test_product_format_declaration.py +++ b/tests/test_product_format_declaration.py @@ -280,7 +280,9 @@ class Response(BaseModel): def test_open_consumer_format_and_params_helper_remain_available() -> None: future = Format(format_kind="future_kind", params={}) assert future.model_dump()["format_kind"] == "future_kind" - image = Format(format_kind="image", params={"width": 300}) + # formats/canonical/image.json states a size-mode mutex outside its properties: + # the fixed mode is width AND height, so width alone declares no mode at all. + image = Format(format_kind="image", params={"width": 300, "height": 250}) assert image.params_as(CanonicalFormatImage).width == 300 diff --git a/tests/test_proposal_lifecycle_e2e.py b/tests/test_proposal_lifecycle_e2e.py index aa9505094..f64fcf27f 100644 --- a/tests/test_proposal_lifecycle_e2e.py +++ b/tests/test_proposal_lifecycle_e2e.py @@ -571,14 +571,32 @@ async def test_update_and_delivery_hydrate_from_reverse_index( @pytest.mark.asyncio -async def test_create_media_buy_rejects_disallowed_pricing_model( +async def test_create_media_buy_cannot_craft_packages_beside_a_proposal( handler: PlatformHandler, store: InMemoryProposalStore, ) -> None: - """Buyer requests a pricing_model outside the recipe's overlap → - INVALID_REQUEST with field='packages[i].pricing_option_id' before - the adapter runs. Per § D4. + """A buyer crafting packages alongside a proposal has no request to send. + + This test used to walk to COMMITTED and then hand the handler a request + carrying BOTH ``proposal_id`` and a ``packages`` array whose + ``_resolved_pricing_model`` sat outside the recipe's overlap, asserting the + § D4 gate refused it with ``field='packages[0].pricing_option_id'`` before + the adapter ran. + + ``create-media-buy-request.json`` forbids that combination: its two + explicit-packages arms ban ``proposal_id`` and its proposal arm bans + ``packages``. The generated model dropped that content, so the request was + constructible and the gate was the only thing standing between it and the + adapter. Now nothing runs at all -- which is the stronger version of + "before the adapter runs" -- and the gate's own refusal is graded at its + call site by + ``tests/test_proposal_lifecycle.py::test_overlap_validation_pricing_model_rejected``. + On the create path the packages the gate inspects are the ones DERIVED from + the proposal's allocations (``proposal_dispatch.py:806``), not any the + buyer sent. """ + from pydantic import ValidationError + from adcp.types import CreateMediaBuyRequest, GetProductsRequest # Walk to COMMITTED. @@ -602,32 +620,56 @@ async def test_create_media_buy_rejects_disallowed_pricing_model( ToolContext(), ) - # Buyer crafts a packages array with pricing_model=cpcv (overlap is cpm). - cmb_req = CreateMediaBuyRequest.model_validate( - { - "proposal_id": PROPOSAL_ID, - "total_budget": {"amount": 50000, "currency": "USD"}, - "start_time": "2026-04-01T00:00:00Z", - "end_time": "2026-06-30T23:59:59Z", - "buyer_ref": "test-buyer-rej", - "idempotency_key": _CMB_IDEM + "reject", - "brand": _BRAND, - "account": _ACCOUNT, - "packages": [ - { - "buyer_ref": "pkg-1", - "product_id": "ctv-premium-q2", - "pricing_option_id": "po-ctv-cpm", - "_resolved_pricing_model": "cpcv", # outside overlap - "budget": 10000, - } - ], - } - ) - with pytest.raises(AdcpError) as exc_info: - await handler.create_media_buy(cmb_req, ToolContext()) - assert exc_info.value.code == "INVALID_REQUEST" - assert "packages[0].pricing_option_id" in (exc_info.value.field or "") + # Buyer crafts a packages array with pricing_model=cpcv (overlap is cpm) + # alongside the proposal it is executing. + cmb_req_fields = { + "proposal_id": PROPOSAL_ID, + "total_budget": {"amount": 50000, "currency": "USD"}, + "start_time": "2026-04-01T00:00:00Z", + "end_time": "2026-06-30T23:59:59Z", + "buyer_ref": "test-buyer-rej", + "idempotency_key": _CMB_IDEM + "reject", + "brand": _BRAND, + "account": _ACCOUNT, + "packages": [ + { + "buyer_ref": "pkg-1", + "product_id": "ctv-premium-q2", + "pricing_option_id": "po-ctv-cpm", + "_resolved_pricing_model": "cpcv", # outside overlap + "budget": 10000, + } + ], + } + + # Asserted as the PIN states the exclusion, not as the message reads. + # media-buy/create-media-buy-request.json (schemas/cache/3.2, AdCP 3.2.1) + # states it per arm, inline: arms 0 and 1 carry + # ``not {"required": ["proposal_id"]}`` and arm 2 carries + # ``not {"anyOf": [{"required": ["packages"]}, ...]}``. So a document carrying + # BOTH keys fails three ``not`` clauses -- proposal_id twice, once for each + # explicit-packages arm, and packages once for the proposal arm. The + # repetition is one issue per failing ARM and not a duplicate. + # + # This asked for "at least one of these field groups" and that is the refusal + # for a document satisfying no arm by OMISSION; `{"buyer_ref": "b"}` earns it, + # with keyword ``anyOf``. This document satisfies no arm by COMMISSION, so it + # earns the per-arm ``not`` -- which is also the keyword + # core/error.json asks an implementer to report, where the combinator message + # on this document would have been the wrong rejection class. + with pytest.raises(ValidationError) as refused: + CreateMediaBuyRequest.model_validate(cmb_req_fields) + entries = refused.value.errors() + assert {entry["type"] for entry in entries} == {"not"}, entries + assert { + key for key in ("proposal_id", "packages") for entry in entries if key in entry["msg"] + } == {"proposal_id", "packages"}, [entry["msg"] for entry in entries] + + # Dropping the packages leaves the proposal arm, which is a request a buyer + # can send -- so the refusal above is about the combination, not the shape. + executable = dict(cmb_req_fields) + executable.pop("packages") + assert CreateMediaBuyRequest.model_validate(executable).proposal_id == PROPOSAL_ID # --------------------------------------------------------------------------- diff --git a/tests/test_protocol_envelope_inheritance.py b/tests/test_protocol_envelope_inheritance.py index 65f1e1dfb..18fab5c90 100644 --- a/tests/test_protocol_envelope_inheritance.py +++ b/tests/test_protocol_envelope_inheritance.py @@ -1,12 +1,12 @@ -"""Every response arm carries the root ``allOf`` protocol envelope (#1136). +"""Every response branch carries the root ``allOf`` protocol envelope (#1136). Each AdCP response schema composes ``core/protocol-envelope.json`` at its root via ``allOf``, so ``status``, ``task_id``, ``message``, ``context_id``, ``replayed``, ``timestamp``, ``push_notification_config``, ``governance_context``, ``context``, ``payload`` and ``adcp_error`` are part of -every response's contract — on the success arm, the error arm and the submitted -arm alike. The code generator used to attach :class:`ProtocolEnvelope` only to -the ``status: submitted`` arm, leaving those fields untyped on the rest: a +every response's contract — on the success branch, the error branch and the submitted +branch alike. The code generator used to attach :class:`ProtocolEnvelope` only to +the ``status: submitted`` branch, leaving those fields untyped on the rest: a seller that set one wrote a pydantic extra and a buyer that read one got an ``AttributeError``. """ @@ -93,8 +93,8 @@ def test_public_response_aliases_declare_every_envelope_field() -> None: "signals/activate_signal_response.py", ], ) -def test_generated_arms_match_their_root_schema_composition(relative: str) -> None: - """Every emitted arm mirrors whether its ROOT schema composes the envelope.""" +def test_generated_branches_match_their_root_schema_composition(relative: str) -> None: + """Every emitted branch mirrors whether its ROOT schema composes the envelope.""" import importlib import re @@ -107,20 +107,22 @@ def test_generated_arms_match_their_root_schema_composition(relative: str) -> No root_composes_envelope = any(ref.endswith("core/protocol-envelope.json") for ref in root_refs) assert root_composes_envelope, f"{relative}: fixture assumes a root protocol envelope" - # ``__all__`` is emitted as ``[union_alias, *numbered_arms, *nested]``; the - # arms are exactly the ``<Base><n>`` names, never the nested helper models. + # ``__all__`` is emitted as ``[union_alias, *numbered_branches, *nested]``; the + # branches are exactly the ``<Base><n>`` names, never the nested helper models. base_name = module.__all__[0] - arm_pattern = re.compile(rf"{re.escape(base_name)}\d+$") - arms = [ - getattr(module, name) for name in module.__all__ if arm_pattern.fullmatch(name) is not None + branch_pattern = re.compile(rf"{re.escape(base_name)}\d+$") + branches = [ + getattr(module, name) + for name in module.__all__ + if branch_pattern.fullmatch(name) is not None ] - assert len(arms) == len(schema["oneOf"]), f"{relative}: arm count drifted from the schema" - for arm in arms: - assert issubclass(arm, ProtocolEnvelope), f"{relative}: {arm.__name__}" + assert len(branches) == len(schema["oneOf"]), f"{relative}: arm count drifted from the schema" + for branch in branches: + assert issubclass(branch, ProtocolEnvelope), f"{relative}: {branch.__name__}" -def test_error_arm_can_carry_envelope_state() -> None: - """The error arm of a ``oneOf`` is an envelope too, not a bare payload.""" +def test_error_branch_can_carry_envelope_state() -> None: + """The error branch of a ``oneOf`` is an envelope too, not a bare payload.""" from adcp.types.aliases import CreateMediaBuyErrorResponse error = CreateMediaBuyErrorResponse.model_validate( @@ -229,7 +231,7 @@ def test_canonical_model_still_round_trips_extension_keys() -> None: def test_canonical_response_keeps_its_schema_pinned_status() -> None: - """Adding the envelope base must not relax an arm's pinned ``status``.""" + """Adding the envelope base must not relax an branch's pinned ``status``.""" response = canonical_creative.UpdateMediaBuyResponse1.model_validate( {"media_buy_id": "mb_1", "revision": 2} ) @@ -266,13 +268,13 @@ def test_canonical_response_status_is_the_generated_one() -> None: This replaces ``test_canonical_response_stub_status_matches_runtime``, whose subject was ``canonical_creative.pyi``: the stub declared a private ``_CanonicalResponseEnvelope`` bridge relaxing ``status`` to ``Any`` so the - arms' ``Literal`` pins would not trip mypy's Liskov check, and this test + branches' ``Literal`` pins would not trip mypy's Liskov check, and this test pinned every concrete response's hand-written re-declaration against the runtime so the bridge could not leak ``Any`` to adopters. The stub is deleted and the canonical responses are real subclasses of the generated wire models, so there is no second declaration of ``status`` to - drift from the first — the arm's pinned ``Literal`` is inherited. The + drift from the first — the branch's pinned ``Literal`` is inherited. The property worth grading is therefore the one that now carries the contract: a canonical response's ``status`` annotation must be IDENTICAL to the annotation of the generated class it refines. A redeclaration that widened diff --git a/tests/test_rc6_adoption.py b/tests/test_rc6_adoption.py index 97df0df63..655da67a0 100644 --- a/tests/test_rc6_adoption.py +++ b/tests/test_rc6_adoption.py @@ -11,7 +11,7 @@ import pytest from jsonschema import FormatChecker from jsonschema.validators import validator_for -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from adcp._version import resolve_adcp_version from adcp.types import GetAdcpCapabilitiesResponse, SyncAccountsAccount, SyncAccountsResponse @@ -257,7 +257,14 @@ def test_anonymous_discovery_rejects_conflicting_account_requirement( "supported_billing": ["operator"], "required_for_products": account_required, } - assert named(relative).is_valid(response) is not (anonymous and account_required) + conflict = anonymous and account_required + assert named(relative).is_valid(response) is not conflict + if conflict: + # The schema states the conflict in its root allOf, so the derived root + # constraint refuses it too: the model and the bundle agree per document. + with pytest.raises(ValidationError): + GetAdcpCapabilitiesResponse.model_validate(response) + return model = GetAdcpCapabilitiesResponse.model_validate(response) assert model.media_buy.anonymous_discovery is anonymous assert model.signals.anonymous_discovery is anonymous diff --git a/tests/test_reliable_reporting_service.py b/tests/test_reliable_reporting_service.py index dae14e5b8..46ccddedf 100644 --- a/tests/test_reliable_reporting_service.py +++ b/tests/test_reliable_reporting_service.py @@ -527,7 +527,25 @@ async def test_first_boot_offerings_require_their_installed_tier(tier: str) -> N "destination_modes": ["existing"], } else: + # A consumer_receipt offering is only a document at all with its method and + # the whole canonical-digest contract (core/reporting-delivery-offering.json, + # allOf member 1). The tier it is missing is reconciled_billing, which is + # what the service has to refuse -- not a malformed offering. offering["reconciliation_mode"] = "consumer_receipt" + offering["method"] = { + "pattern": "file_transfer", + "transport": "https", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + } + offering["reporting_profile"] = { + **offering["reporting_profile"], + "canonicalization_id": "rows-v1", + "canonicalization_contract_version": "1.0", + "canonicalization_media_type": "application/vnd.adcp.reporting-canonicalization+json", + "canonicalization_uri": "https://contracts.example.test/rows-v1.json", + "canonicalization_sha256": "d" * 64, + } service.sources.register( "gam", ScriptedReportingAdapter(redacted_capabilities(), []), diff --git a/tests/test_reporting_adjustment_evidence.py b/tests/test_reporting_adjustment_evidence.py index 7bcbc360d..ef65be1b4 100644 --- a/tests/test_reporting_adjustment_evidence.py +++ b/tests/test_reporting_adjustment_evidence.py @@ -29,6 +29,7 @@ ReportingRevision, ) from adcp.validation.schema_loader import get_named_validator +from tests.test_reporting_reconciliation import _issue SECRET = "private-body-marker" SCOPE = ReportingAdjustmentScope( @@ -484,19 +485,24 @@ def selected() -> tuple[ReportingObligation, ReportingRevision]: "period_duration": "P1M", "alignment": "billing_cycle", "delivery_sla": "P1D", + "period_anchor": "2026-01-01T00:00:00Z", + "period_timezone": "UTC", }, "destination_ref": "destination-1", "required_finality": "official", "reconciliation_mode": "consumer_receipt", "reconciliation_status": "pending", - "health": "waiting", + # Published, consumer_receipt and pending: members 2 and 8 of + # core/reporting-obligation.json require action_required with a + # RECEIPT_REQUIRED issue, not "waiting" with none. + "health": "action_required", "production_status": "published", "revision_count": 1, "materialization_count": 1, "successful_materialization_count": 1, "receipt_count": 0, "accepted_receipt_count": 0, - "issues": [], + "issues": [_issue("RECEIPT_REQUIRED")], "resource_retained_until": "2026-12-01T00:00:00Z", } ) diff --git a/tests/test_reporting_capability_models.py b/tests/test_reporting_capability_models.py index 6410070af..db8c64da0 100644 --- a/tests/test_reporting_capability_models.py +++ b/tests/test_reporting_capability_models.py @@ -22,9 +22,67 @@ } +#: What allOf member 3 requires of a managed_delivery tier: the two retention +#: windows, and an offering naming the delivery method. +MANAGED = { + "resource_retention_days": 90, + "authorization_revocation_seconds": 3600, + "offerings": [ + { + **_OFFERING, + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "destination_modes": ["existing"], + "provider": {"domain": "aws.amazon.com"}, + "format": "jsonl", + }, + } + ], +} + +#: allOf member 9 of the response: a seller emitting a reporting notification signs +#: its outbound webhooks. +WEBHOOK_SIGNING = { + "supported": True, + "profile": "adcp/webhook-signing/v1", + "algorithms": ["ed25519"], + "legacy_hmac_fallback": False, +} + + +def _reconciled_offering(): + """The managed offering under the reconciled-billing tier. + + allOf member 4 of the capabilities requires a consumer_receipt offering, and + member 1 of core/reporting-delivery-offering.json then requires that offering's + profile to state the whole canonical-digest contract. + """ + offering = dict(MANAGED["offerings"][0]) + offering["reconciliation_mode"] = "consumer_receipt" + offering["reporting_profile"] = { + **offering["reporting_profile"], + "canonicalization_id": "rows-v1", + "canonicalization_contract_version": "1.0", + "canonicalization_media_type": "application/vnd.adcp.reporting-canonicalization+json", + "canonicalization_uri": "https://contracts.example.test/canonicalization/rows-v1.json", + "canonicalization_sha256": "c" * 64, + } + return offering + + def capability(**fields): + if fields.get("managed_delivery") is True: + fields = {**MANAGED, **fields} + if fields.get("reconciled_billing") is True and fields.get("managed_delivery") is True: + fields = {**fields, "offerings": [_reconciled_offering()]} return { "supported": True, + # core/reporting-delivery-capabilities.json requires both tasks at its root; + # neither is a tier promise, so neither belongs in PROMISES. + "configuration_task": "sync_accounts", + "status_task": "get_reporting_status", "offerings": [_OFFERING], "automated_recovery_window_seconds": 0, "status_retention_days": 7, @@ -40,19 +98,31 @@ def response(raw): "idempotency": {"supported": True, "replay_ttl_seconds": 3600}, }, "supported_protocols": ["media_buy"], + # A seller declaring media_buy.reporting_delivery declares the experimental + # feature that names it (protocol/get-adcp-capabilities-response.json). + "experimental_features": ["media_buy.reporting_delivery"], + "webhook_signing": dict(WEBHOOK_SIGNING), "media_buy": {"reporting_delivery": raw}, } @pytest.fixture(params=["public", "bundled"]) def graph(request): + """``(reporting-delivery model, envelope, carries the derived root constraints)``. + + Only the public model does. The clone's reporting_delivery is an inline nested + object inside the bundled response file, and the reader compiles a schema's ROOT + constraints, so the clone is graded by the response's own conjuncts plus the + authored tier validator. The two graphs therefore refuse the same documents with + different refusals, and a test naming one has to say which graph it is in. + """ if request.param == "public": - return ReportingDeliveryCapabilities, GetAdcpCapabilitiesResponse + return ReportingDeliveryCapabilities, GetAdcpCapabilitiesResponse, True # Codegen's self-contained clone is deliberately tested as a separate graph. module = importlib.import_module( "adcp.types.domains.bundled.protocol.get_adcp_capabilities_response" ) - return module.ReportingDelivery, module.GetAdcpCapabilitiesResponse + return module.ReportingDelivery, module.GetAdcpCapabilitiesResponse, False @pytest.mark.parametrize( @@ -65,7 +135,7 @@ def graph(request): ], ) def test_core_attributes_schema_and_json_round_trip_do_not_synthesize_promises(graph, flags): - model, envelope = graph + model, envelope, derived = graph value = model.model_validate(capability(**flags)) fields = set(value.model_fields_set) schema = model.model_json_schema() @@ -95,33 +165,55 @@ def test_core_attributes_schema_and_json_round_trip_do_not_synthesize_promises(g @pytest.mark.parametrize( - "field,tier", - [("readiness_notification", "managed_delivery"), ("receipt_task", "reconciled_billing")], + "field,tier,derived_refusal", + [ + # core/reporting-delivery-capabilities.json states this one in its root + # allOf, so where the derived constraint exists it names it and arrives first. + ( + "readiness_notification", + "managed_delivery", + r"root conjunct 7\) over managed_delivery, readiness_notification", + ), + # The schema root states no inverse for this one, so the authored validator + # is the only owner in either graph. + ("receipt_task", "reconciled_billing", None), + ], + ids=["readiness_notification-managed_delivery", "receipt_task-reconciled_billing"], ) @pytest.mark.parametrize("flag", [None, False]) def test_inverse_tier_validation_runs_in_both_standalone_and_nested_graphs( - graph, field, tier, flag + graph, field, tier, flag, derived_refusal ): - model, envelope = graph + model, envelope, derived = graph + refusal = derived_refusal if derived and derived_refusal else f"{field} requires {tier}" raw = capability(**{field: PROMISES[field], **({tier: flag} if flag is not None else {})}) - with pytest.raises(ValidationError, match=f"{field} requires {tier}"): + with pytest.raises(ValidationError, match=refusal): model.model_validate(raw) - with pytest.raises(ValidationError, match=f"{field} requires {tier}"): + with pytest.raises(ValidationError, match=refusal): envelope.model_validate(response(raw)) @pytest.mark.parametrize("managed", [None, False]) def test_reconciled_is_cumulative_even_without_a_receipt_task(graph, managed): - model, envelope = graph + model, envelope, derived = graph + # The tier is not free-standing: allOf member 4 requires a receipt_task and a + # consumer_receipt offering, and that offering's own method then requires + # managed_delivery through member 5, so the cumulative rule is the schema's own. + # The clone, carrying no derived root constraint, refuses through the authored one. + refusal = ( + r"root conjunct 4\) over offerings, receipt_task, reconciled_billing" + if derived + else "reconciled_billing requires managed_delivery" + ) raw = capability(reconciled_billing=True, managed_delivery=managed) for cls, body in ((model, raw), (envelope, response(raw))): - with pytest.raises(ValidationError, match="reconciled_billing requires managed_delivery"): + with pytest.raises(ValidationError, match=refusal): cls.model_validate(body) @pytest.mark.parametrize("field", PROMISES) def test_each_explicit_supported_literal_survives_without_implying_other_promises(graph, field): - model, envelope = graph + model, envelope, derived = graph flags = {"managed_delivery": True} if field == "readiness_notification" else {} if field == "receipt_task": flags = {"managed_delivery": True, "reconciled_billing": True} diff --git a/tests/test_reporting_consumer_loop.py b/tests/test_reporting_consumer_loop.py index 69b9a878c..11606958d 100644 --- a/tests/test_reporting_consumer_loop.py +++ b/tests/test_reporting_consumer_loop.py @@ -77,6 +77,17 @@ def _obligation(**overrides: Any) -> ReportingObligation: "reconciliation_status": "not_required", } payload.update(overrides) + # core/reporting-obligation.json states these ties outside its properties: + # published implies at least one revision (member 5) and healthy implies both + # (member 1), so an obligation with no revision yet is pending and waiting; and + # naming a current consumer status implies having counted one (member 0). + if payload["revision_count"] == 0: + if "production_status" not in overrides: + payload["production_status"] = "pending" + if "health" not in overrides: + payload["health"] = "waiting" + if "current_consumer_status_id" in overrides and "consumer_status_count" not in overrides: + payload["consumer_status_count"] = 1 return ReportingObligation.model_validate(payload) @@ -108,6 +119,12 @@ def _revision(**overrides: Any) -> ReportingRevision: "schema_version": "1.0.0", } payload.update(overrides) + # core/reporting-revision.json member 1: an official revision states what made + # it official, under which policy, and when. + if payload["finality"] == "official": + payload.setdefault("finality_basis", "source_final") + payload.setdefault("finality_policy_id", "daily_v1_official") + payload.setdefault("finalized_at", payload["created_at"]) return ReportingRevision.model_validate(payload) diff --git a/tests/test_reporting_exact_request_models.py b/tests/test_reporting_exact_request_models.py index 18e99260e..4ca6bee73 100644 --- a/tests/test_reporting_exact_request_models.py +++ b/tests/test_reporting_exact_request_models.py @@ -62,7 +62,10 @@ def test_explicit_aggregate_selectors_are_forbidden_even_when_null(field): def test_exact_mode_rejects_explicit_boolean_flags_without_mutation(field, flag): raw = {**exact_request(), field: flag} before = deepcopy(raw) - with pytest.raises(ValidationError, match="exact revision requests forbid aggregate selectors"): + # The schema states the exclusion in its root allOf, so the derived constraint + # names the selectors an exact-revision request may not carry. It reads the + # document rather than the value, which is why a FALSE flag is refused too. + with pytest.raises(ValidationError, match=r"root conjunct 0\) over attribution_window"): GetMediaBuyDeliveryRequest.model_validate(raw) assert raw == before @@ -95,7 +98,9 @@ def test_aggregate_defaults_and_explicit_flags_are_preserved(flags): def test_pagination_requires_an_exact_revision(): - with pytest.raises(ValidationError, match="pagination requires reporting_revision_id"): + with pytest.raises( + ValidationError, match=r"root conjunct 1\) over pagination, reporting_revision_id" + ): GetMediaBuyDeliveryRequest.model_validate({"pagination": {"max_results": 100}}) diff --git a/tests/test_reporting_frozen_read_corrections.py b/tests/test_reporting_frozen_read_corrections.py index 6095771c7..fc5b5bdd0 100644 --- a/tests/test_reporting_frozen_read_corrections.py +++ b/tests/test_reporting_frozen_read_corrections.py @@ -26,9 +26,11 @@ _history, _load, _pages, + _pending, _request, _with_status, ) +from tests.test_reporting_reconciliation import _delivery_only_complete @pytest.mark.parametrize("explicit", [False, True]) @@ -58,10 +60,11 @@ async def test_delivery_only_adjustments_require_each_accepted_current_leaf( raw["adjustments"].append(second) _counts(raw) owner = raw["periods"][0] - owner.update(reconciliation_mode="delivery_only", reconciliation_status="not_required") + # Leaving the consumer-receipt health behind would make the obligation report + # OBLIGATION_ACTION_REQUIRED instead of the adjustment condition under test. + owner.update(**_delivery_only_complete()) for item in [owner, *raw["materializations"], *raw["scope"]["delivery_config_generations"]]: item["feed_purpose"] = "analytics" - owner["schedule"].update(period_anchor=owner["period"]["start"], period_timezone="UTC") # These counts do not apply in delivery-only mode. Adjustment evidence must # remain a separate condition even when the seller omits the pending count. for field in ( @@ -69,8 +72,11 @@ async def test_delivery_only_adjustments_require_each_accepted_current_leaf( "accepted_receipt_count", "adjustment_receipt_count", "accepted_adjustment_receipt_count", + # The accepted-receipt fixture declares it; this scenario's whole point is + # that the seller may omit it, so the omission has to be explicit here. + "pending_adjustment_count", ): - owner.pop(field) + owner.pop(field, None) accepted = evidence in {"accepted", "accepted-leaf"} if declare_pending: owner["pending_adjustment_count"] = int(not accepted) @@ -107,7 +113,6 @@ def _distinct_histories(count: int, *, distinction: str = "campaigns") -> dict[s receipt_count=0, accepted_receipt_count=0, ) - owner["schedule"].update(period_anchor=owner["period"]["start"], period_timezone="UTC") if distinction == "campaigns": buys = [f"buy-{number}-a", f"buy-{number}-b"] for item in (owner, revision): @@ -179,17 +184,15 @@ async def test_ownerless_revisionless_status_does_not_certify_multiple_obligatio @pytest.mark.parametrize("explicit", [False, True]) -@pytest.mark.parametrize("binding", ["owner", "revision"]) -async def test_exact_status_binding_counts_once_even_when_logical_keys_match(explicit, binding): +# Only the owner binding: core/reporting-consumer-status.json member 0 requires the +# owner, the revision and the observed digest on a received status, so a status +# without its owner is not a document this walk can be handed. The owner-free +# already-typed legacy tolerance is not reachable through the wire the walk reads. +async def test_exact_status_binding_counts_once_even_when_logical_keys_match(explicit): raw = _distinct_histories(2) status = _with_status(_history())["consumer_statuses"][0] status["reporting_revision_id"] = raw["revisions"][0]["reporting_revision_id"] - if binding == "owner": - status["reporting_obligation_id"] = raw["periods"][0]["reporting_obligation_id"] - else: - # Pure already-typed legacy tolerance; rc6 received-status wire still - # requires the owner, and these tests claim no mounted compatibility. - status.pop("reporting_obligation_id") + status["reporting_obligation_id"] = raw["periods"][0]["reporting_obligation_id"] raw["consumer_statuses"] = [status] raw["periods"][0].update( consumer_status_count=1, current_consumer_status_id=status["reporting_status_id"] @@ -254,9 +257,6 @@ async def test_absent_optional_count_is_diagnostic_but_contradictory_count_is_re ): raw = _history(adjustments=True) raw["periods"][0]["health"] = "waiting" - raw["periods"][0]["schedule"].update( - period_anchor=raw["periods"][0]["period"]["start"], period_timezone="UTC" - ) actual = raw["periods"][0].pop(field) # These tier counts are schema-optional; missing evidence is not a false count. # Keep the conditional requirements for healthy/complete projections intact. @@ -306,7 +306,10 @@ def _partly_owned_legacy_history() -> dict[str, Any]: @pytest.mark.parametrize( ("field", "declared"), [ - ("revision_count", 0), + # No under-count for revision_count: core/reporting-obligation.json members + # 1 and 5 make a published, healthy obligation carry at least one revision, + # so zero is not a document. The lower bound is exercised by the adjustment + # counts below, which the schema leaves free. ("revision_count", 3), ("adjustment_count", 0), ("adjustment_count", 2), @@ -359,7 +362,7 @@ async def test_pending_adjustment_count_cannot_invent_ownership_of_selected_offi assert not result.definitive assert all("AMBIGUOUS_REVISION_OWNERSHIP" in item.reasons for item in result.obligations) # Even ambiguous ownership cannot explain more pending rows than exist. - raw["periods"][0]["pending_adjustment_count"] = 2 + _pending(raw["periods"][0], 2) with pytest.raises(ReportingReconciliationError) as error: await _load_distinct(raw, explicit=False) assert error.value.code == "LEDGER_COUNT_MISMATCH" diff --git a/tests/test_reporting_frozen_read_safety.py b/tests/test_reporting_frozen_read_safety.py index 09504695a..fa20d88a6 100644 --- a/tests/test_reporting_frozen_read_safety.py +++ b/tests/test_reporting_frozen_read_safety.py @@ -8,6 +8,7 @@ from typing import Any import pytest +from pydantic import ValidationError from adcp.reporting import ( ExpectedReportingPeriod, @@ -25,6 +26,7 @@ REVISION, TOTALS, _full_finality_scope, + _health, _response, ) @@ -128,6 +130,61 @@ def _with_status(raw: dict[str, Any]) -> dict[str, Any]: return raw +def _pending(obligation: dict[str, Any], count: int) -> None: + """Declare an outstanding-adjustment count with the health it implies. + + core/reporting-obligation.json member 7 says a complete consumer_receipt + obligation has none outstanding, so a declared count moves health off complete + and member 8 then requires the issue that names what is outstanding. + """ + obligation["pending_adjustment_count"] = count + if obligation.get("reconciliation_mode") == "consumer_receipt": + obligation.update( + _health("complete") + if not count + else _health("action_required", code="ADJUSTMENT_RECEIPT_REQUIRED") + ) + + +def _reconciled(obligation: dict[str, Any]) -> None: + """Derive reconciliation_status, health and issues from the receipt counts. + + core/reporting-obligation.json states the tie outside its properties: a + consumer_receipt obligation is complete only with an accepted receipt and no + outstanding adjustment (member 7), is action_required with a RECEIPT_REJECTED + issue when every receipt was rejected (member 3), and is action_required with a + RECEIPT_REQUIRED issue while none has arrived (member 2). Recomputing the counts + without this leaves a document the schema refuses, which the reader then reports + as STATUS_READ_FAILED rather than as the condition under test. + """ + if obligation.get("reconciliation_mode") != "consumer_receipt": + return + if obligation.get("accepted_receipt_count"): + # Outstanding adjustments are the ones whose current leaf is not accepted; + # a stale declared count contradicts the evidence the reader recomputes. + pending = max( + obligation.get("adjustment_count", 0) + - obligation.get("accepted_adjustment_receipt_count", 0), + 0, + ) + obligation.update(reconciliation_status="accepted", pending_adjustment_count=pending) + obligation.update( + _health("complete") + if not pending + else _health("action_required", code="ADJUSTMENT_RECEIPT_REQUIRED") + ) + elif obligation.get("receipt_count"): + obligation.update( + reconciliation_status="rejected", + **_health("action_required", code="RECEIPT_REJECTED"), + ) + else: + obligation.update( + reconciliation_status="pending", + **_health("action_required", code="RECEIPT_REQUIRED"), + ) + + def _counts(raw: dict[str, Any]) -> None: """Recompute fixture counts; mutation tests deliberately run after this.""" for obligation in raw["periods"]: @@ -150,6 +207,7 @@ def _counts(raw: dict[str, Any]) -> None: r["status"] == "accepted" for r in raw.get("adjustment_receipts", []) ), ) + _reconciled(obligation) raw["pagination"] = { "has_more": False, "total_count": sum(len(raw.get(name, [])) for name in ARRAYS), @@ -250,19 +308,23 @@ async def get_reporting_status(self, request): async def test_health_finality_and_exact_revision_selectors_cannot_hide_history() -> None: - client = Pages(_pages(_history())) - request = _request( - view="revision", - reporting_revision_id=REVISION_ID, - health=["action_required"], - finality=["official"], - ) - await load_reporting_ledger(client, request) - assert all(r.view.value == "periods" for r in client.requests) - assert all( - r.health is None and r.finality is None and r.reporting_revision_id is None - for r in client.requests - ) + # No single request carries all three: media-buy/get-reporting-status-request.json + # states (allOf members 2 and 3) that a revision view REQUIRES + # reporting_revision_id and FORBIDS health and finality, while a periods view + # forbids reporting_revision_id. So the two selector families are asked + # separately, and the loader must drop each of them. + for changes in ( + {"view": "revision", "reporting_revision_id": REVISION_ID}, + {"health": ["action_required"], "finality": ["official"]}, + ): + client = Pages(_pages(_history())) + await load_reporting_ledger(client, _request(**changes)) + assert client.requests + assert all(r.view.value == "periods" for r in client.requests) + assert all( + r.health is None and r.finality is None and r.reporting_revision_id is None + for r in client.requests + ) @pytest.mark.parametrize("outside", ["horizon", "generation", "media-buy", "retention"]) @@ -282,7 +344,20 @@ async def test_expected_period_outside_the_proven_scope_is_not_an_obligation_mis elif outside == "media-buy": expected = replace(expected, media_buy_ids=("outside-authorized-scope",)) else: + # Declaring incomplete coverage is not a lone flag: + # ``get-reporting-status-response.json`` requires the seller to report + # it as an action-required health with a HISTORY_UNAVAILABLE issue. raw["scope"]["coverage_complete"] = False + raw["health"] = "action_required" + raw["issues"] = [ + { + "issue_id": "history-unavailable", + "code": "HISTORY_UNAVAILABLE", + "severity": "action_required", + "responsible_party": "seller", + "recommended_action": "contact_seller", + } + ] result = evaluate_reporting_ledger(await _load(raw), expected_periods=[expected], now=NOW) assert not result.definitive assert result.missing_expected_periods == [] @@ -331,22 +406,44 @@ async def test_completed_snapshot_cannot_be_edited_into_different_evidence() -> @pytest.mark.parametrize("field", ["changes_checkpoint", "next_expected_at", "health"]) async def test_frozen_metadata_is_checked_including_wholly_legacy_pages(explicit, field) -> None: pages = _pages(_history(), explicit=explicit) - pages[-1][field] = { - "changes_checkpoint": "different-checkpoint", - "next_expected_at": "2026-10-01T00:00:00Z", - "health": "waiting", - }[field] + # Each entry is the whole update, not one value, because ``health`` cannot + # be set on its own: ``get-reporting-status-response.json`` requires an + # empty ``issues`` array alongside a health of healthy, waiting or + # complete, so a page carrying only ``health`` is one the response schema + # refuses. The differing field is still what this test is about. + pages[-1].update( + { + "changes_checkpoint": {"changes_checkpoint": "different-checkpoint"}, + "next_expected_at": {"next_expected_at": "2026-10-01T00:00:00Z"}, + "health": {"health": "waiting", "issues": []}, + }[field] + ) with pytest.raises(ReportingReconciliationError) as error: await load_reporting_ledger(Pages(pages), _request(), max_snapshot_restarts=0) assert error.value.code == "SNAPSHOT_CHANGED" async def test_every_page_requires_the_frozen_total() -> None: + """A page without the total is refused, and the response schema is what refuses it. + + ``get-reporting-status-response.json``'s periods view requires + ``pagination.total_count`` -- along with every other field + ``_reconcile``'s ``INCOMPLETE_LEDGER_PAGE`` guard tests by hand + (``ledger_snapshot_id``, ``ledger_as_of``, ``account_id``, ``scope``, + ``pagination.has_more``). That conditional used to be dropped from the + generated model, so the guard was the only thing enforcing it and a page + missing the total reached it. Now the read fails first, which is why the + obligation is asserted where it now lives as well as at the reconciler. + """ pages = _pages(_history()) pages[0]["pagination"].pop("total_count") + + with pytest.raises(ValidationError): + GetReportingStatusResponse.model_validate(pages[0]) + with pytest.raises(ReportingReconciliationError) as error: await load_reporting_ledger(Pages(pages), _request(), max_snapshot_restarts=0) - assert error.value.code == "INCOMPLETE_LEDGER_PAGE" + assert error.value.code == "STATUS_READ_FAILED" @pytest.mark.parametrize( @@ -410,12 +507,31 @@ async def test_positive_status_count_requires_its_exact_current_status_dependenc assert error.value.code == "INVALID_LEDGER_DEPENDENCY" +# An owner-free consumer status used to be read here as though the binding fell +# back to the revision. core/reporting-consumer-status.json requires +# reporting_obligation_id of every consumer_status except obligation_missing +# (members 0 and 2 to 4), and obligation_missing FORBIDS it along with any +# revision binding (member 1) -- so the pinned wire carries an owner-free status +# only in the one shape that has no revision to fall back to. +# +# Both halves are graded, and neither is this test's old claim: the refusal below, +# and the owner-free tolerance on a loaded ledger in +# test_ownerless_revisionless_status_does_not_certify_multiple_obligations +# (tests/test_reporting_frozen_read_corrections.py), which uses the +# obligation_missing status the spec permits it on. + + async def test_legacy_typed_status_without_owner_uses_exact_revision_binding() -> None: + """A received status states its owner, so an unowned one never reaches binding.""" raw = _with_status(_history()) raw["consumer_statuses"][0].pop("reporting_obligation_id") - ledger = await _load(raw) - assert ledger.consumer_statuses[0].reporting_obligation_id is None - assert evaluate_reporting_ledger(ledger, expected_periods=_expected(), now=NOW).definitive + + with pytest.raises(ValidationError): + GetReportingStatusResponse.model_validate(_pages(raw)[-1]) + + with pytest.raises(ReportingReconciliationError) as error: + await _load(raw) + assert error.value.code == "STATUS_READ_FAILED" @pytest.mark.parametrize("bounds", [{"max_bytes": 100}, {"max_records": 4}]) @@ -651,12 +767,16 @@ async def test_accepted_receipt_stays_bound_to_its_materialization_after_later_o newer.update(failed_at="2026-09-02T00:01:06Z", failure_code="WRITE_FAILED") elif later == "expired": newer["resource"]["expires_at"] = "2026-09-02T23:00:00Z" - elif later == "corrupt": - raw["periods"][0]["health"] = "action_required" elif later == "bad-producer-evidence": newer["verification"]["row_count"] = 999 raw["materializations"].append(newer) _counts(raw) + if later == "corrupt": + # After the count pass, which derives health from the receipt evidence and + # would otherwise put this obligation back to complete. The code names + # what went wrong here: the receipt arrived and was accepted, so + # RECEIPT_REQUIRED would be the one issue this obligation does not have. + raw["periods"][0].update(_health("action_required", code="DELIVERY_FAILED")) result = evaluate_reporting_ledger(await _load(raw), expected_periods=_expected(), now=NOW) reasons = result.obligations[0].reasons assert "MISSING_MATCHING_CONSUMER_RECEIPT" not in reasons @@ -758,10 +878,13 @@ async def test_pending_adjustment_count_matches_only_the_current_leaf(accepted) if not accepted: raw["adjustment_receipts"][0].update(status="rejected", rejection_codes=["MISMATCH"]) _counts(raw) - raw["periods"][0]["pending_adjustment_count"] = int(not accepted) + _pending(raw["periods"][0], int(not accepted)) result = evaluate_reporting_ledger(await _load(raw), expected_periods=_expected(), now=NOW) assert result.definitive is accepted - raw["periods"][0]["pending_adjustment_count"] = int(accepted) + # _pending moves the health off complete, and that alone would carry the + # assertion above, so the adjustment condition is named on its own. + assert ("MISSING_MATCHING_ADJUSTMENT_RECEIPT" in result.obligations[0].reasons) is not accepted + _pending(raw["periods"][0], int(accepted)) with pytest.raises(ReportingReconciliationError) as error: await _load(raw) assert error.value.code == "LEDGER_COUNT_MISMATCH" diff --git a/tests/test_reporting_interop_corpus.py b/tests/test_reporting_interop_corpus.py index 1eaaa64a7..37e50264f 100644 --- a/tests/test_reporting_interop_corpus.py +++ b/tests/test_reporting_interop_corpus.py @@ -4,6 +4,7 @@ import importlib.util import json import subprocess +import sys from pathlib import Path import pytest @@ -862,20 +863,35 @@ class FakePool {{ assert.throws(() => privateInputsModule.storyboardPrivateInputs({{ ...storyboardEnvironment, ADCP_INTEROP_TS_STORYBOARD_STARTUP_PROOF: 'short', }}), /strong per-run private input/); - const ready = core.buildReadyRecord({{ + // The core seller's ready record carries an owned-start proof, and + // ts_core_server.cjs's processStartToken() reads /proc/<pid>/stat + // field 22 for it -- so it fail-closes off Linux by design. Both + // halves are asserted, and the marker each writes tells the caller + // which one ran: neither platform skips this block silently. + const coreReadyArgs = [{{ expectedIntegrity: 'sha512-fixture', installed: {{ version: '14.0.0-test' }}, packageName: '@adcp/sdk', }}, {{ adcpVersion: '3.2.0-rc.6', authBindings: bindings, port: 55555, sellerRole: 'candidate', startupProof: privateInputs.startupProof, url: 'http://127.0.0.1:55555/mcp', - }}); - const encodedReady = JSON.stringify(ready); - assert.equal(encodedReady.includes(strongA), false); - assert.equal(encodedReady.includes(strongB), false); - assert.equal(encodedReady.includes(privateInputs.startupProof), false); - assert.equal(ready.seller_package.package_role, 'candidate'); - assert.equal(ready.seller_package.protocol, '3.2.0-rc.6'); + }}]; + if (process.platform === 'linux') {{ + const ready = core.buildReadyRecord(...coreReadyArgs); + const encodedReady = JSON.stringify(ready); + assert.equal(encodedReady.includes(strongA), false); + assert.equal(encodedReady.includes(strongB), false); + assert.equal(encodedReady.includes(privateInputs.startupProof), false); + assert.equal(ready.seller_package.package_role, 'candidate'); + assert.equal(ready.seller_package.protocol, '3.2.0-rc.6'); + process.stdout.write('ts-core-ready-record-complete\\n'); + }} else {{ + assert.throws( + () => core.buildReadyRecord(...coreReadyArgs), + /requires Linux \\/proc/, + ); + process.stdout.write('ts-core-ready-record-requires-linux\\n'); + }} for (const fixtureReady of [ controlled.buildReadyRecord(fakeApi, {{ adcpVersion: '3.2.0-rc.6', authToken: strongA, port: 55555, @@ -910,7 +926,18 @@ class FakePool {{ text=True, timeout=30, ) - assert completed.stdout.splitlines() == ["ts-version-abort-helpers-complete"] + # The core seller's owned-start proof needs Linux ``/proc``; off Linux the + # script asserts that it fail-closes instead. Which marker appears is + # therefore an assertion about the platform, not a skipped obligation. + core_marker = ( + "ts-core-ready-record-complete" + if sys.platform.startswith("linux") + else "ts-core-ready-record-requires-linux" + ) + assert completed.stdout.splitlines() == [ + core_marker, + "ts-version-abort-helpers-complete", + ] assert "urllib.request.urlopen" in receiver_server assert '"normative_019_match"' in receiver_client assert '"blocking_acceptance"' in receiver_client diff --git a/tests/test_reporting_interop_matrix.py b/tests/test_reporting_interop_matrix.py index a99692db5..073d3b4f6 100644 --- a/tests/test_reporting_interop_matrix.py +++ b/tests/test_reporting_interop_matrix.py @@ -177,7 +177,13 @@ def complete_inputs() -> dict: value["execution"].update( { "postgresql_version": "16.14", - "os_release_sha256": hashlib.sha256(Path("/etc/os-release").read_bytes()).hexdigest(), + # The gate checks this digest's SHAPE and never its value + # (``_digest(..., HEX_64)`` in reporting_interop_matrix.py), so the + # fixture needs *a* digest rather than the host's OS identity. + # Hashing /etc/os-release made a pure fixture builder fail on every + # machine without that file, which is every non-Linux box: the + # obligation graded here is the gate's, not the runner's. + "os_release_sha256": hashlib.sha256(b"os-release fixture").hexdigest(), "seller_entrypoint": str(seller_entrypoint), "seller_entrypoint_sha256": hashlib.sha256(SCRIPT.read_bytes()).hexdigest(), "buyer_entrypoint": str(buyer_entrypoint), diff --git a/tests/test_reporting_reconciliation.py b/tests/test_reporting_reconciliation.py index d5429ae9f..bb47235c6 100644 --- a/tests/test_reporting_reconciliation.py +++ b/tests/test_reporting_reconciliation.py @@ -1,11 +1,13 @@ from __future__ import annotations import asyncio +import dataclasses import hashlib import json import threading from copy import deepcopy from datetime import datetime +from typing import Any from urllib.parse import urljoin import httpx @@ -103,6 +105,9 @@ def test_capability_uses_public_reporting_delivery_model() -> None: "supported": True, "managed_delivery": True, "reconciled_billing": True, + # reconciled_billing names the task consumers submit receipts through + # (core/reporting-delivery-capabilities.json, allOf member 4). + "receipt_task": "sync_reporting_receipts", "offerings": [ { "offering_id": "billing-daily", @@ -115,9 +120,18 @@ def test_capability_uses_public_reporting_delivery_model() -> None: "version": "1", "schema_uri": "https://schemas.example/reporting/billing-v1.json", "schema_sha256": "c" * 64, + "schema_dialect": "https://json-schema.org/draft/2020-12/schema", + "schema_ref_policy": "local_fragment_only", "grain": "media_buy_day", "primary_keys": ["media_buy_id", "date"], + # A consumer_receipt offering's profile carries the whole + # canonical-digest contract, not three of its five fields + # (core/reporting-delivery-offering.json, allOf member 1). "canonicalization_id": "rows-v1", + "canonicalization_contract_version": "1.0", + "canonicalization_media_type": ( + "application/vnd.adcp.reporting-canonicalization+json" + ), "canonicalization_uri": "https://schemas.example/canonicalization/rows-v1.json", "canonicalization_sha256": "b" * 64, }, @@ -181,6 +195,83 @@ def test_capability_uses_public_reporting_delivery_model() -> None: } +def _issue(code: str) -> dict[str, object]: + """One reporting issue. A published consumer_receipt obligation whose receipt is + pending or rejected is action_required and MUST carry an issue naming which + (core/reporting-obligation.json, allOf members 2, 3 and 8).""" + return { + "issue_id": f"issue-{code.lower().replace('_', '-')}", + "code": code, + "severity": "action_required", + "responsible_party": "buyer", + "recommended_action": "contact_buyer", + } + + +def _restate(record: Any, **changes: Any) -> Any: + """A typed record with fields restated, built without re-validating it.""" + return record.model_construct(**{**record.__dict__, **changes}) + + +def _restated(ledger: Any, field: str, index: int, record: Any) -> Any: + """``ledger`` with one of its typed records replaced. + + ``evaluate_reporting_ledger`` is a pure function over typed records, and some of + its checks guard states the pinned schema refuses outright: an official revision + with no finality evidence (core/reporting-revision.json member 1), or a resource + kind and verification path disagreeing with the method (members 2 to 4 of + core/reporting-materialization.json, which fix both from the method). No + validated response can carry one, and a loaded ledger refuses mutation, so the + record is restated and the ledger rebuilt around it. That grades the check at + the boundary it actually defends. + """ + records = list(getattr(ledger, field)) + records[index] = record + return dataclasses.replace(ledger, **{field: tuple(records)}) + + +def _health(value: str, *, code: str = "RECEIPT_REQUIRED") -> dict[str, object]: + """``health`` together with the issues the schema ties to it. + + One owner for THAT tie: core/reporting-obligation.json states it outside its + properties, member 1 giving a healthy or complete obligation NO open issue + and member 8 giving a delayed or action_required one at least one. Setting + health alone leaves a document the schema refuses, which is why the two are + one return value. + + It owns NOTHING ELSE, and two members say so. Members 2 and 3 read + ``reconciliation_status`` and name the code a pending or rejected receipt + requires, which is why ``code`` is a parameter -- a published + consumer_receipt obligation that is still pending is action_required + whatever this returns -- and member 7 requires all three receipt counts, + ``pending_adjustment_count`` among them, once such an obligation reads + healthy or complete. So a caller moving the health moves those in the same + update. Measured against the bundled validator rather than inferred: + ``_health("complete")`` over :func:`_obligation` is refused on member 2, and + refused on member 7 even once the status is settled. :func:`_reconciled` is + the one that owns the whole derivation. + """ + closed = value in ("healthy", "complete") + return {"health": value, "issues": [] if closed else [_issue(code)]} + + +def _delivery_only_complete() -> dict[str, object]: + """The state an obligation reaches when delivery alone settles it. + + Fourteen sites in this module put an obligation into exactly this state, and + the schema makes it four keys rather than three: member 6 ties delivery_only + to a not_required status, and member 1 forbids an open issue at a complete + health, which is why the health comes through :func:`_health` rather than as + a literal. A function and not a constant, so each site gets its own + ``issues`` list to mutate. + """ + return { + "reconciliation_mode": "delivery_only", + "reconciliation_status": "not_required", + **_health("complete"), + } + + def _obligation(identifier: str = "obligation-billing") -> dict[str, object]: return { "reporting_obligation_id": identifier, @@ -199,12 +290,22 @@ def _obligation(identifier: str = "obligation-billing") -> dict[str, object]: "period_duration": "P1M", "alignment": "billing_cycle", "delivery_sla": "P1D", + # billing_cycle requires both: the anchor fixes the recurring cycle and + # the timezone defines its calendar arithmetic. Anchored ON the period + # rather than on a literal that happens to derive it, so the monthly + # boundaries stay exactly PERIOD if PERIOD ever moves. + "period_anchor": PERIOD["start"], + "period_timezone": "UTC", }, "destination_ref": f"destination-{identifier}", "required_finality": "official", "reconciliation_mode": "consumer_receipt", "reconciliation_status": "pending", - "health": "waiting", + # A published consumer_receipt obligation whose receipt is still pending is + # action_required carrying a RECEIPT_REQUIRED issue, which the schema states + # outside its properties (core/reporting-obligation.json, allOf members 2 + # and 8). "waiting" with no issues is a document the schema refuses. + "health": "action_required", "production_status": "published", "revision_count": 1, "materialization_count": 1, @@ -214,7 +315,7 @@ def _obligation(identifier: str = "obligation-billing") -> dict[str, object]: "adjustment_count": 0, "adjustment_receipt_count": 0, "accepted_adjustment_receipt_count": 0, - "issues": [], + "issues": [_issue("RECEIPT_REQUIRED")], "resource_retained_until": "2026-12-01T00:00:00Z", } @@ -263,9 +364,18 @@ def _response(receipts: list[dict[str, object]] | None = None) -> dict[str, obje accepted_receipts = [receipt for receipt in receipts if receipt.get("status") == "accepted"] item.update(receipt_count=len(receipts), accepted_receipt_count=len(accepted_receipts)) if accepted_receipts: - item.update(reconciliation_status="accepted", health="complete") + # Member 7 also requires the three receipt counts on a complete + # consumer_receipt obligation, pending_adjustment_count among them. + item.update( + reconciliation_status="accepted", + pending_adjustment_count=0, + **_health("complete"), + ) else: - item.update(reconciliation_status="rejected", health="waiting") + item.update( + reconciliation_status="rejected", + **_health("action_required", code="RECEIPT_REJECTED"), + ) return { "status": "completed", "view": "periods", @@ -353,9 +463,7 @@ async def get_reporting_status( ): obligation.pop(field, None) obligation.update( - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), ) revision = deepcopy(REVISION) revision.pop("canonical_content_digest") @@ -1171,9 +1279,7 @@ async def test_partial_reporting_coverage_prevents_definitive_result() -> None: ) raw["periods"][0].update( coverage=partial, - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), ) raw["revisions"][0]["coverage"] = deepcopy(partial) @@ -1236,9 +1342,7 @@ async def test_one_revision_cannot_materialize_under_two_obligations() -> None: second = _obligation("obligation-b") for item in (first, second): item.update( - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), ) raw["periods"] = [first, second] raw["materializations"] = [ @@ -1276,9 +1380,7 @@ async def test_current_publication_is_selected_before_its_materialization( obligation.update( required_finality=finality, revision_count=2, - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), ) older = deepcopy(REVISION) older.update(reporting_revision_id="older-materialized-snapshot", finality="snapshot") @@ -1375,9 +1477,7 @@ async def test_official_close_cannot_mask_a_broken_snapshot_history( raw = _response() raw["periods"][0].update( revision_count=3, - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), required_finality="official", materialization_count=1, successful_materialization_count=1, @@ -1407,9 +1507,7 @@ async def test_identical_scope_obligations_each_keep_their_own_materialized_publ for item in (first, second): item.update( revision_count=1, - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), required_finality="official", materialization_count=1, successful_materialization_count=1, @@ -1447,18 +1545,14 @@ async def test_unowned_publication_never_falls_back_to_an_older_materialized_sna second.update(delivery_config_id="analytics-feed", feed_purpose="analytics") first.update( revision_count=2, - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), required_finality="snapshot", materialization_count=1, successful_materialization_count=1, ) second.update( revision_count=1, - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), required_finality="official", materialization_count=1, successful_materialization_count=1, @@ -1501,9 +1595,7 @@ async def test_native_commit_requires_a_native_version_resource_descriptor() -> raw = _response() raw["periods"][0].update( revision_count=1, - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), required_finality="official", feed_purpose="analytics", materialization_count=1, @@ -1565,9 +1657,7 @@ async def test_mismatched_native_producer_evidence_is_not_definitive() -> None: obligation = raw["periods"][0] assert isinstance(obligation, dict) obligation.update( - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), ) attempt = raw["materializations"][0] assert isinstance(attempt, dict) @@ -1582,6 +1672,12 @@ async def test_mismatched_native_producer_evidence_is_not_definitive() -> None: }, ) verification.pop("canonical_content_digest") + # core/reporting-materialization.json member 5: a BILLING materialization that + # is available or delivered verifies by canonical digest. Native commit is an + # analytics-feed profile, so the whole generation states that purpose. + for item in [obligation, attempt, *raw["scope"]["delivery_config_generations"]]: + item["feed_purpose"] = "analytics" + raw["scope"]["feed_purposes"] = ["analytics"] class NativeMismatchClient(_Client): async def get_reporting_status( @@ -1613,15 +1709,8 @@ async def test_official_revision_requires_finality_evidence() -> None: obligation = raw["periods"][0] assert isinstance(obligation, dict) obligation.update( - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), ) - revision = raw["revisions"][0] - assert isinstance(revision, dict) - revision.pop("finality_basis") - revision.pop("finality_policy_id") - revision.pop("finalized_at") class MissingFinalityEvidenceClient(_Client): async def get_reporting_status( @@ -1638,6 +1727,17 @@ async def get_reporting_status( {"account": {"account_id": "account-1"}, "view": "periods"} ), ) + ledger = _restated( + ledger, + "revisions", + 0, + _restate( + ledger.revisions[0], + finality_basis=None, + finality_policy_id=None, + finalized_at=None, + ), + ) result = evaluate_reporting_ledger( ledger, expected_periods=[], @@ -1654,18 +1754,8 @@ async def test_materialization_method_evidence_must_match_method() -> None: obligation = raw["periods"][0] assert isinstance(obligation, dict) obligation.update( - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), ) - materialization = raw["materializations"][0] - assert isinstance(materialization, dict) - resource = materialization["resource"] - verification = materialization["verification"] - assert isinstance(resource, dict) - assert isinstance(verification, dict) - resource["kind"] = "warehouse_relation" - verification["verification_path"] = "destination" class MethodMismatchClient(_Client): async def get_reporting_status( @@ -1682,6 +1772,17 @@ async def get_reporting_status( {"account": {"account_id": "account-1"}, "view": "periods"} ), ) + materialization = ledger.materializations[0] + ledger = _restated( + ledger, + "materializations", + 0, + _restate( + materialization, + resource=_restate(materialization.resource, kind="warehouse_relation"), + verification=_restate(materialization.verification, verification_path="destination"), + ), + ) result = evaluate_reporting_ledger( ledger, expected_periods=[], @@ -1698,9 +1799,7 @@ async def test_superseded_revision_must_be_present() -> None: obligation = raw["periods"][0] assert isinstance(obligation, dict) obligation.update( - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), ) revision = raw["revisions"][0] assert isinstance(revision, dict) @@ -1808,9 +1907,7 @@ async def test_core_obligation_without_materialization_can_be_definitive() -> No obligation.pop("accepted_receipt_count") obligation.pop("resource_retained_until") obligation.update( - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", + **_delivery_only_complete(), ) raw["materializations"] = [] raw["pagination"] = {"has_more": False, "total_count": 2} diff --git a/tests/test_reporting_revision_ownership.py b/tests/test_reporting_revision_ownership.py index 5ab6699f9..e6e4ae3bd 100644 --- a/tests/test_reporting_revision_ownership.py +++ b/tests/test_reporting_revision_ownership.py @@ -17,7 +17,12 @@ ) from adcp.types import GetReportingStatusRequest, GetReportingStatusResponse from adcp.types.core import TaskResult, TaskStatus -from tests.test_reporting_reconciliation import REVISION, _obligation, _response +from tests.test_reporting_reconciliation import ( + REVISION, + _delivery_only_complete, + _obligation, + _response, +) ARRAYS = ( "periods", @@ -216,14 +221,14 @@ async def test_explicit_ownership_separates_identical_scopes_and_legacy_stays_co raw = _response() raw["periods"].append(_obligation("obligation-other")) raw["revisions"].append({**deepcopy(REVISION), "reporting_revision_id": "revision-other"}) - raw["periods"][1].update( - destination_ref=None, - materialization_count=None, - successful_materialization_count=None, - reconciliation_mode="delivery_only", - reconciliation_status="not_required", - health="complete", - ) + # An obligation with no destination is exempt from the materialization + # counts core/reporting-obligation.json allOf[4] requires of one that has a + # destination, and the way a document says "no destination" is to omit the + # key: destination_ref is a string and the counts are integers, so an + # explicit null is a value of the wrong type rather than an absence. + for field in ("destination_ref", "materialization_count", "successful_materialization_count"): + raw["periods"][1].pop(field, None) + raw["periods"][1].update(**_delivery_only_complete()) owned = await load(pages(raw)) result = evaluate_reporting_ledger(owned, now=datetime(2026, 9, 3, tzinfo=timezone.utc)) assert result.obligations[1].reporting_revision_id == "revision-other" diff --git a/tests/test_reporting_wire_defaults.py b/tests/test_reporting_wire_defaults.py index fe2425163..04efbefcb 100644 --- a/tests/test_reporting_wire_defaults.py +++ b/tests/test_reporting_wire_defaults.py @@ -23,15 +23,23 @@ def test_reporting_defaults_are_explicit_in_actual_bundled_and_canonical_models( "status_retention_days": 30, } if explicit: - supplied.update(reliable_reporting_version="1.0", managed_delivery=False) + # core/reporting-delivery-capabilities.json member 2: declaring the + # Reliable Reporting version names the exact-content read task, so that + # task is set here and every other one stays unset. + supplied.update( + reliable_reporting_version="1.0", + managed_delivery=False, + revision_content_task="get_media_buy_delivery", + ) model = model_type.model_validate({"reporting_delivery": supplied}) before = set(model.reporting_delivery.model_fields_set) default_status_task = model.reporting_delivery.status_task + declared_tasks = {"revision_content_task"} if explicit else set() for wire in (model.model_dump(mode="json"), json.loads(model.model_dump_json())): value = wire["reporting_delivery"] assert ("reliable_reporting_version" in value) is explicit assert ("managed_delivery" in value) is explicit - assert not any(key.endswith(("_task", "_notification")) for key in value) + assert {key for key in value if key.endswith(("_task", "_notification"))} == declared_tasks assert "supports_webhook_activity" not in value if explicit: assert value["reliable_reporting_version"] == "1.0" @@ -40,7 +48,10 @@ def test_reporting_defaults_are_explicit_in_actual_bundled_and_canonical_models( assert model.reporting_delivery.status_task == default_status_task -@pytest.mark.parametrize("product_event,explicit", [(False, False), (False, True), (True, False)]) +# core/notification-config.json member 0 ties the view to the event: a product.* +# subscription carries one and a non-product subscription may not, so a +# non-product event with an explicit view is not a document at all. +@pytest.mark.parametrize("product_event,explicit", [(False, False), (True, False), (True, True)]) def test_nested_notification_defaults_follow_event_and_field_provenance(product_event, explicit): class Envelope(BaseModel): config: NotificationConfig diff --git a/tests/test_request_validation_no_input_echo.py b/tests/test_request_validation_no_input_echo.py new file mode 100644 index 000000000..924f8c593 --- /dev/null +++ b/tests/test_request_validation_no_input_echo.py @@ -0,0 +1,218 @@ +"""A rejected request must not come back carrying what the peer sent. + +``str(ValidationError)`` renders pydantic's diagnostic with ``input_value=`` +in it, so a handler that put one in a protocol error reflected the peer's own +payload — a bearer token, PII, a prompt-injection string — back out through a +wire envelope that logs and intermediaries see. It also put pydantic's +internal error-type names (``model_type``) in a field no AdCP consumer can +act on. + +What the peer IS owed is ``core/error.json``'s ``issues[]``: the pointer to +the offending field and the rule that rejected it. Both halves are graded +here — silence would be as wrong as echoing. + +The handler methods under test are DERIVED from the source of the three +handler classes, not listed, so a method added later is covered without +editing this file; ``test_every_validating_method_is_discovered`` fails if +that derivation ever stops finding them. +""" + +from __future__ import annotations + +import inspect +import json +import re + +import pytest + +from adcp.server.base import NotImplementedResponse +from adcp.server.content_standards import ContentStandardsHandler +from adcp.server.governance import GovernanceHandler +from adcp.server.sponsored_intelligence import SponsoredIntelligenceHandler + +HANDLER_CLASSES = (ContentStandardsHandler, GovernanceHandler, SponsoredIntelligenceHandler) + +# A value a peer could put in a request that must never come back out. +SECRET = "Bearer sk-live-SHOULD-NOT-APPEAR" + +# Pydantic's own diagnostic vocabulary. None of it is in the JSON Schema +# keyword vocabulary core/error.json points at, so none of it belongs on the +# wire. ``input_value``/``input_type`` are the value echo itself. +PYDANTIC_INTERNALS = ( + "input_value", + "input_type", + "model_type", + "model_attributes_type", + "literal_error", + "string_pattern_mismatch", + "For further information visit", +) + +# Every payload names the secret and is malformed for every request model in +# the suite: a bare string is never a valid request object, and a lone +# unknown/`account` key never satisfies a request model's required fields. +POISON_PAYLOADS = ( + SECRET, + {"account": SECRET}, + {"idempotency_key": SECRET, "unexpected_field": SECRET}, + [SECRET], +) + + +def _validating_methods(handler_cls: type) -> list[str]: + """Names of the request-validation methods this class defines. + + Keyed on ``model_validate(params)`` — what makes a method one of these + paths — and deliberately NOT on ``invalid_request``, the helper that + fixes them. Keying on the fix makes the suite grade only the sites that + already have it: a fire-probe that put ``str(e)`` back at one site saw + the behavioural tests stay GREEN, because reintroducing the defect + removed that site from the population instead of failing it. + """ + found = [] + for name, member in vars(handler_cls).items(): + if name.startswith("_") or name.startswith("handle_"): + continue + if not inspect.iscoroutinefunction(member): + continue + try: + source = inspect.getsource(member) + except OSError: # pragma: no cover - source always available here + continue + if "model_validate(params)" in source: + found.append(name) + return sorted(found) + + +class _Accepted: + """What a stub ``handle_*`` returns when a payload validated after all. + + Not every request model has a required field — ``list_content_standards`` + and ``list_property_lists`` accept a bare unknown key — so some poison + payloads legitimately get through to the handler. Returning a sentinel + rather than a response lets the caller tell "was rejected, assert on it" + from "was accepted, nothing to assert", and the per-method ``rejected`` + count is what keeps a method from being graded on zero rejections. + """ + + +def _concrete(handler_cls: type) -> object: + """A handler whose abstract ``handle_*`` methods all return the sentinel.""" + + async def _accepted(*args: object, **kwargs: object) -> object: + return _Accepted() + + namespace = {name: _accepted for name in handler_cls.__abstractmethods__} + return type(f"Concrete{handler_cls.__name__}", (handler_cls,), namespace)() + + +ALL_METHODS = [(cls, name) for cls in HANDLER_CLASSES for name in _validating_methods(cls)] + + +def test_every_validating_method_is_discovered(): + """The derivation must find the sites, or every test below is vacuous. + + 21 is the count measured at d6fff01db by + ``git grep -n 'message=str(e)' -- src/adcp/server/`` over the three + handler modules. It is a floor: a new validating method raises it, and + this assertion only fails if the derivation stops seeing them. + """ + assert len(ALL_METHODS) >= 21, ALL_METHODS + for handler_cls in HANDLER_CLASSES: + assert _validating_methods(handler_cls), handler_cls.__name__ + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("handler_cls", "method_name"), ALL_METHODS, ids=lambda v: getattr(v, "__name__", v) +) +async def test_rejection_does_not_echo_the_payload(handler_cls, method_name): + """No part of the refusal carries the value or pydantic's internals.""" + handler = _concrete(handler_cls) + method = getattr(handler, method_name) + + rejected = 0 + for payload in POISON_PAYLOADS: + result = await method(payload) + if not isinstance(result, NotImplementedResponse): + continue + rejected += 1 + blob = json.dumps(result.model_dump(mode="json", exclude_none=True)) + + assert SECRET not in blob, f"{method_name} echoed the payload value: {blob}" + for token in PYDANTIC_INTERNALS: + assert token not in blob, f"{method_name} leaked pydantic internals ({token}): {blob}" + + assert rejected, f"no poison payload was rejected by {method_name}; the test proved nothing" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("handler_cls", "method_name"), ALL_METHODS, ids=lambda v: getattr(v, "__name__", v) +) +async def test_rejection_is_actionable(handler_cls, method_name): + """The peer is told the field path and the rule, per core/error.json. + + ``issues[]`` requires ``pointer``, ``message`` and ``keyword``, and the + keyword is "drawn from the JSON Schema vocabulary" — so a pydantic error + type appearing there is a defect even though it is not a value echo. + """ + json_schema_keywords = { + "required", + "type", + "enum", + "const", + "pattern", + "format", + "additionalProperties", + "minLength", + "maxLength", + "minItems", + "maxItems", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "oneOf", + "anyOf", + "allOf", + "not", + # schema_validator's own spelling for "no keyword applies". + "validation", + } + + handler = _concrete(handler_cls) + result = await getattr(handler, method_name)(SECRET) + + assert isinstance(result, NotImplementedResponse) + assert result.error is not None + assert result.error.code == "VALIDATION_ERROR" + # core/error.json: 3.2 producers MUST populate recovery; a malformed + # request is the enum's own example of "correctable". + assert result.error.recovery == "correctable" + + assert result.error.issues, f"{method_name} reported no issues[]" + for issue in result.error.issues: + entry = issue if isinstance(issue, dict) else issue.model_dump() + assert entry["pointer"].startswith("/"), entry + assert entry["message"].strip(), entry + assert entry["keyword"] in json_schema_keywords, entry + + +def test_source_has_no_remaining_stringified_validation_error(): + """The echo cannot come back by the spelling it arrived in. + + Guards the three handler modules against ``message=str(e)`` / + ``reason=f"...{e}"`` returning at a new site — the whole point of routing + every one of them through a single helper. + """ + offenders = [] + for handler_cls in HANDLER_CLASSES: + module = inspect.getmodule(handler_cls) + source = inspect.getsource(module) + for match in re.finditer( + r"(?:message|reason)\s*=\s*(?:str\(\s*e\w*\s*\)|f\"[^\"]*\{e\w*\})", source + ): + offenders.append(f"{module.__name__}: {match.group(0)}") + assert not offenders, offenders diff --git a/tests/test_root_conjunct_defaults.py b/tests/test_root_conjunct_defaults.py new file mode 100644 index 000000000..2c93e04c7 --- /dev/null +++ b/tests/test_root_conjunct_defaults.py @@ -0,0 +1,538 @@ +"""A model must not accept a document and then emit one it would itself refuse. + +A compiled root conjunct asks SENT-ness -- ``model_fields_set`` -- which is the +right question for ``required`` on an input and the wrong one for a conditional +the schema applies to the SERIALIZED document. There a default is +indistinguishable from a value the caller sent, so a defaulted field slips +through ``_c.descend`` (vacuous for a field that was not supplied) and is then +serialized. ``protocol/get-principal-response.json`` ties ``status`` to +``result.kind``; with ``status`` unset the model accepted a failed result and +dumped ``status='completed'``, which that conditional forbids (salesagent-38fdf). + +THIS GRADES THE DEFECT, NOT A SHAPE. Two static descriptions of "the dangerous +shape" were tried first and neither selects the right set: "reads its own +defaulted field in the CHECK" admits 37 sites at this commit, most of them safe +because the check also demands the field be sent; adding "or in a guard with no +predicate" and excluding a check that demands sent-ness gives 9, which is a +DIFFERENT 9 from the 8 the bead's own probe found -- overlapping in the two real +escapes and disagreeing in both directions elsewhere. A count that matches is not +a set that matches. + +So no shape is used. For each class that carries a conjunct and reads one of its +own fields that has a serializing default, a document the class ACCEPTS with that +field unset is synthesized, dumped, and fed back. If the class refuses its own +dump, it admitted a state its schema forbids and then emitted it. That is the +defect stated as itself, and it needs no allowlist: the expected answer is none. +""" + +from __future__ import annotations + +import ast +import inspect +import itertools +import sys +import textwrap +from contextlib import contextmanager +from typing import Any, get_args + +from pydantic import BaseModel, ValidationError +from pydantic_core import PydanticUndefined + +# Importing the consolidated module loads every generated domain module, and the +# canonical layer is a separate import. The derivation walks what is loaded. +import adcp.types._generated # noqa: F401 +import adcp.types.canonical_creative # noqa: F401 +from tests.conformance import _schema_parity as parity + +#: The validator ``enforce_root_constraints`` emits, which is what makes a class +#: carry a compiled root conjunct at all. +ROOT_CONJUNCT = "_require_schema_required_group" + + +class _LocalDefs: + """A ``$ref`` resolver over one pydantic schema's own ``$defs``.""" + + def __init__(self, schema: dict[str, Any]) -> None: + self._defs = schema.get("$defs", {}) + + @contextmanager + def resolving(self, ref: str) -> Any: + yield self._defs[ref.rsplit("/", 1)[-1]] + + +def _generated_models() -> dict[tuple[str, str], type[BaseModel]]: + """Every loaded model, keyed by ``(module, qualname)``. + + Both halves: an exported name hides aliases, and ``__qualname__`` alone + collapses two distinct classes that share a name. + """ + found: dict[tuple[str, str], type[BaseModel]] = {} + for module in list(sys.modules.values()): + if module is None or not getattr(module, "__name__", "").startswith("adcp.types"): + continue + for value in vars(module).values(): + if isinstance(value, type) and issubclass(value, BaseModel): + found[(value.__module__, value.__qualname__)] = value + return found + + +def _carries_conjunct(model: type[BaseModel]) -> bool: + """Whether ``model`` is graded by a compiled root conjunct, inherited or its own. + + The MRO, not ``model.__dict__``: a subclass INHERITS the validator, and + narrowing a field the inherited conjunct reads is exactly how the generator + spells a schema that pins a member. ``__dict__`` alone selected the 225 + classes that DECLARE a conjunct and excluded the 238 that inherit one -- + including ``accept_proposal_request.Opportunity``, which pins + ``status`` to ``Literal['closed']`` over ``OpportunityContext``'s conjunct and + was one of three classes refusing a document its schema accepts while this + file was green (salesagent-8qela.51). + """ + return any(ROOT_CONJUNCT in base.__dict__ for base in model.__mro__) + + +def _conjunct_fields(model: type[BaseModel]) -> set[str]: + """Every field name a conjunct grading this class reads off ``self``. + + Read from the validator's source rather than from the schema, because what + the conjunct actually asks is what decides the escape -- and ``_c.descend`` + is the primitive that is vacuous for an unsupplied field. + + Every base that declares one is read, for the reason + :func:`_carries_conjunct` gives: the conjunct that grades a subclass is + frequently not written on it. + """ + sources = [ + base.__dict__[ROOT_CONJUNCT] for base in model.__mro__ if ROOT_CONJUNCT in base.__dict__ + ] + return set().union(*(_fields_read_by(function) for function in sources)) if sources else set() + + +def _fields_read_by(function: Any) -> set[str]: + """The names one compiled validator passes to a ``_c.*`` primitive on ``self``.""" + raw = getattr(function, "__func__", function) + raw = getattr(raw, "wrapped", raw) + try: + source = textwrap.dedent(inspect.getsource(raw)) + except (OSError, TypeError): + return set() + names: set[str] = set() + for node in ast.walk(ast.parse(source)): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "_c" + and len(node.args) >= 2 + and isinstance(node.args[0], ast.Name) + and node.args[0].id == "self" + and isinstance(node.args[1], ast.Constant) + ): + names.add(node.args[1].value) + return names + + +def _serializing_defaults(model: type[BaseModel], fields: set[str]) -> list[str]: + """Those fields whose default is a value, so it reaches the wire.""" + out = [] + for name in sorted(fields): + info = model.model_fields.get(name) + if info is None: + continue # a nested member's field that happens to share a name + if info.default is None or info.default is PydanticUndefined: + continue # nothing is emitted for it, so nothing can escape + out.append(name) + return out + + +def _accepted_documents_without(model: type[BaseModel], unset: list[str]) -> list[dict[str, Any]]: + """Documents the model accepts which carry none of ``unset``.""" + schema = model.model_json_schema(mode="validation") + try: + candidates = parity.instances(schema, _LocalDefs(schema), budget=8) + except Exception: + return [] + good = [] + for candidate in candidates[:40]: + if not isinstance(candidate, dict) or any(name in candidate for name in unset): + continue + try: + model.model_validate(candidate) + except ValidationError: + continue + good.append(candidate) + return good + + +def _exposure() -> tuple[list[tuple[str, str, list[str]]], list[str]]: + """``(graded, ungraded)`` over every class a conjunct could let a default escape.""" + graded: list[tuple[str, str, list[str]]] = [] + ungraded: list[str] = [] + for (module, qualname), model in sorted(_generated_models().items()): + risky = _serializing_defaults(model, _conjunct_fields(model)) + if not risky: + continue + documents = _accepted_documents_without(model, risky) + if not documents: + ungraded.append(f"{module}.{qualname}") + continue + graded.append((module, qualname, risky)) + return graded, ungraded + + +def test_no_conjunct_lets_a_defaulted_value_escape_and_then_serializes_it() -> None: + """The escape, stated as itself: accepted, then emitted and refused. + + Re-validating the DUMP is the whole assertion. If a class accepts a document + and its own dump of that object does not validate, the conjunct graded a + state the wire never carries -- which is what sent-ness asks and what the + schema's conditional does not. + """ + graded, _ungraded = _exposure() + # A derivation that matches nothing is green, so the population is asserted. + # Measured over the classes a conjunct GRADES rather than those that declare + # one: 26 classes, 31 (class, field) pairs, with 16 more carrying a risky + # default and no synthesizable document. Keyed on ``__dict__`` it was 10 + # classes (salesagent-8qela.51). + assert len(graded) >= 26, f"the derivation selected only {len(graded)} classes to grade" + + escapes: list[str] = [] + for module, qualname, risky in graded: + model = _generated_models()[(module, qualname)] + for document in _accepted_documents_without(model, risky): + dumped = model.model_validate(document).model_dump(mode="json", exclude_none=True) + emitted = [name for name in risky if name in dumped] + if not emitted: + continue + try: + model.model_validate(dumped) + except ValidationError as refusal: + escapes.append( + f"{module}.{qualname}: accepted a document with {emitted} unset, then " + f"emitted {{{', '.join(f'{n}={dumped[n]!r}' for n in emitted)}}} and " + f"refused its own dump ({refusal.errors()[0]['type']})" + ) + break + + assert not escapes, "\n".join(escapes) + + +def test_the_two_repaired_responses_grade_the_status_they_emit() -> None: + """The two escapes 38fdf fixed, named, so the repair cannot be quietly undone. + + 38fdf repaired these with ``DERIVED_DEFAULTS``, a ``mode='after'`` validator + that supplied ``status`` from ``result.kind`` so the conjunct would grade a + SENT value. That table is gone, because the conjunct now grades the status + the DOCUMENT carries whether or not the caller typed it, and a derivation + running after it could not be reached: both documents it existed to repair + are refused first (salesagent-8qela.27). + + So the contract this holds is the same obligation with the other outcome. + ``protocol/get-principal-response.json`` permits only 'failed' or 'rejected' + beside a failed result, and the envelope's ergonomic default is 'completed'; + the response therefore REFUSES the construction that leaves status to that + default, naming it, instead of accepting it and emitting a document the + schema forbids. Named as well as covered by the derivations above, because + both depend on documents remaining synthesizable for these two classes and a + schema refresh could take that away. + """ + from adcp.types.domains.protocol.get_principal_response import GetPrincipalResponse + from adcp.types.domains.protocol.sync_principal_response import SyncPrincipalResponse + + failed = { + "adcp_version": "3.2", + "result": {"kind": "failed", "errors": [{"code": "AUTH_MISSING", "message": "no"}]}, + } + for model in (GetPrincipalResponse, SyncPrincipalResponse): + name = model.__name__ + # Unset: the ergonomic default contradicts the result, and is refused for + # the value the document would carry -- not for having been left out. + with pytest_raises_validation(model, failed): + pass + # The same refusal with ``kind`` left to its own default, which is the + # escape 38fdf's before-validator attempt could not see and left open. + no_kind = {"result": {"errors": [{"code": "AUTH_MISSING", "message": "no"}]}} + with pytest_raises_validation(model, {"adcp_version": "3.2", **no_kind}): + pass + # Explicitly contradicting the result is still refused, unchanged. + with pytest_raises_validation(model, {**failed, "status": "completed"}): + pass + + # A status the conditional permits is accepted, and its dump is a document + # the class accepts -- which is what makes the refusals above a contract + # about the value and not a class that refuses every failed result. + stated = model.model_validate({**failed, "status": "failed"}) + assert stated.status.value == "failed" + dumped = stated.model_dump(mode="json", exclude_none=True) + assert dumped["status"] == "failed" + model.model_validate(dumped) + + # A result that does not fire the conditional keeps the ergonomic default. + # The non-failing arm is DERIVED per class: the two responses do not share + # one -- get_principal has 'unconfigured', sync_principal does not -- and a + # hardcoded arm made this test fail for the wrong reason. + other = _a_non_failing_result(model) + kept = model.model_validate({"adcp_version": "3.2", "result": other}) + assert kept.status.value == "completed", ( + f"{name} refused result={other!r}, which does not fire the conditional, so " + "the ergonomic default should stand" + ) + + +def _reaching_documents(model: type[BaseModel], without: str) -> list[dict[str, Any]]: + """Documents that reach the arms of ``model``'s conditional, none carrying ``without``. + + The synthesizer alone does not reach them, and cannot: a root + ``if``/``then`` is precisely the content ``datamodel-code-generator`` + dropped, so it is absent from the model's own JSON schema and + ``_object_instances`` populates only what that schema marks ``required`` -- + which, on a response whose every field is optional, is the empty document. + Measured: over the whole tree it reached one of the four instances the bead + proved and missed three. + + So each field the conjunct READS is treated as a knob, since those are the + fields a conditional turns on, and the family is the synthesized base plus + every one- and two-knob combination. Two is what the proven instances need + (``{unchanged, wholesale_feed_version}``), and the knobs are few enough per + class that the pairs are cheap. Values come from the same synthesizer, per + property, so the arm that keys on a literal gets that literal. + """ + schema = model.model_json_schema(mode="validation") + defs = _LocalDefs(schema) + try: + base = [d for d in parity.instances(schema, defs, budget=4) if isinstance(d, dict)][:3] + except Exception: + base = [] + values: dict[str, Any] = {} + for knob in _conjunct_fields(model): + if knob == without or knob not in model.model_fields: + continue + prop = (schema.get("properties") or {}).get(knob) + if prop is None: + continue + try: + synthesized = parity.instances(prop, defs, budget=2) + except Exception: + continue + if synthesized and synthesized[0] is not None: + values[knob] = synthesized[0] + + family = [] + for document in base or [{}]: + family.append(document) + for size in (1, 2): + for combination in itertools.combinations(sorted(values), size): + family.append({**document, **{knob: values[knob] for knob in combination}}) + return [document for document in family if without not in document] + + +def _sent_value(model: type[BaseModel], name: str) -> Any: + """The field's own default, as a caller would type it.""" + default = model.model_fields[name].default + return getattr(default, "value", default) + + +def _dumps_the_same_unsent(instance: BaseModel, name: str) -> bool: + """Whether ``instance`` emits ``name`` identically with its sent-ness discarded. + + The whole question. When it holds, the document the caller gets is the same + one either way, so a refusal that turns on having typed the value is grading + something the wire does not carry. When it does not, the class's serializer + drops the unset default -- ``GetMediaBuyDeliveryRequest`` has two such + fields -- the wire really is missing it, and refusing is correct. + """ + unsent = instance.model_copy() + unsent.__pydantic_fields_set__.discard(name) + return unsent.model_dump(mode="json", exclude_none=True) == instance.model_dump( + mode="json", exclude_none=True + ) + + +def test_no_conjunct_demands_a_default_the_document_already_carries() -> None: + """The mirror of the escape: identical documents, and one of them refused. + + A compiled conjunct asks SENT-ness, and a schema's ``required`` asks what the + DOCUMENT carries. Where a field's default is one the class emits, those two + part company: the dump carries the field whether or not the caller typed it, + so the schema's ``required`` is satisfied either way -- and the conjunct + refuses the construction that omitted it. The two documents are + byte-identical, which is the whole proof that the refusal grades nothing the + wire can express (salesagent-k37fs). + + Stated as itself and not as a shape: a pair is a defect only when the class + REFUSES the document without the field, ACCEPTS the same document with the + field's own default typed, and emits the same bytes for both. Each clause + carries its own weight. A class that refuses for an unrelated reason refuses + both ways and never qualifies, so no allowlist stands in for that; and a + class whose serializer drops the unset default fails the third clause, which + is how the exception is derived rather than listed. + + WHAT THIS OBLIGATION DOES NOT SEE, measured, so nobody re-derives it. The + population now covers the classes a conjunct GRADES rather than those that + declare one, which added the 238 inheriting subclasses -- and the three that + refused a document their bundled schema accepts + (``{accept_proposal,buy_products,create_media_buy}_request.Opportunity.status``, + salesagent-8qela.50) are in it and ARE examined. They are disqualified by the + second clause, correctly: each refuses the document with ``status='closed'`` + TYPED as well as unset, because what is missing is ``close_reason``, so the + refusal is not about sent-ness and this is not the defect stated here. + Their defect is a model refusing what the schema accepts, which + ``tests/conformance/test_schema_model_parity.py`` owns and caught. Widening + this population does not surface them and no clause here should be stretched + until it does; that would duplicate an obligation another suite already + grades. + + EVERY field with an emitted default is a candidate, not only one the conjunct + NAMES. ``_c.members`` names none: ``core/deadline-policy.json`` states + ``minProperties: 1`` and the conjunct reads ``self`` whole, so + ``DeadlinePolicy.business_days_only`` is invisible to the conjunct's text and + is one of the four instances. Narrowing to the named fields is the kind of + shape that looked right and selected the wrong set. + """ + pairs = [ + (module, qualname, name) + for (module, qualname), model in sorted(_generated_models().items()) + if _carries_conjunct(model) + for name in _serializing_defaults(model, set(model.model_fields)) + ] + # A derivation that matches nothing is green, and one whose population is + # narrower than the thing it grades is green over whatever it left out. + # Measured over the classes a conjunct GRADES -- 463, of which 225 declare one + # and 238 inherit one: 615 (class, field) pairs over 262 classes. Keyed on + # ``__dict__`` this read 189 over 89, so 426 pairs went ungraded + # (salesagent-8qela.51). + # + # A LOWER BOUND, not a count, and taken from the SMALLEST composition. + # ``_generated_models`` walks what the PROCESS has imported, so a sibling test + # that loads more generated modules can only enlarge this. Measured both ways + # at this commit: 615 pairs with this file alone and 615 beside + # ``test_schema_model_parity.py`` -- but the number of pairs that go on to + # produce a document moved (585 alone, 587 together), so both floors are + # written from the file-alone run. + assert len(pairs) >= 615, f"the derivation selected only {len(pairs)} (class, field) pairs" + + demanded: list[str] = [] + compared: set[tuple[str, str, str]] = set() + for module, qualname, name in pairs: + model = _generated_models()[(module, qualname)] + sent_value = _sent_value(model, name) + for document in _reaching_documents(model, name): + try: + with_field = model.model_validate({**document, name: sent_value}) + except ValidationError: + continue # not a document this class accepts, so there is no pair + if not _dumps_the_same_unsent(with_field, name): + continue # the serializer drops it: the wire really is missing it + # Two spellings of ONE document. The class has to accept both. + compared.add((module, qualname, name)) + try: + model.model_validate(document) + except ValidationError: + demanded.append( + f"{module}.{qualname}: refused a document with {name} unset, accepted the " + f"same document with {name}={sent_value!r}, and dumps both as " + f"{with_field.model_dump(mode='json', exclude_none=True)}" + ) + break + + # The search has to have produced pairs, or the comparison compared nothing. + # This counts the pairs it actually built, not the ones that came out clean -- + # a count of clean pairs falls as defects are found and reads as progress. + # Measured: 585 of the 615 pairs had one; the other 30 have no document this + # class accepts that carries the field, so there is no pair to grade. A lower + # bound for the same reason the pairs floor is one. + assert len(compared) >= 585, f"only {len(compared)} pair(s) produced a document to compare" + assert not demanded, "\n".join(demanded) + + +def test_the_proven_sent_ness_pairs_dump_identically() -> None: + """The four instances the bead proved, named, so the repair cannot go quiet. + + Named as well as covered by the derivation above, for the reason the two + repaired responses are: the derivation depends on a document being reachable + for each class, and a schema refresh could take that away -- and then the + cases anyone has actually seen would stop being graded with nothing failing. + + ``DeadlinePolicy`` is here because it is the one that is not a ``required`` + at all: ``core/deadline-policy.json`` states ``minProperties: 1``, so the + count is what ``business_days_only``'s emitted default satisfies, and + ``_c.members`` had counted ``model_fields_set`` instead of the document. + """ + from adcp.types.domains.core.deadline_policy import DeadlinePolicy + from adcp.types.domains.core.reporting_resource import ReportingResource + from adcp.types.domains.media_buy.get_products_response import GetProductsResponse + from adcp.types.domains.signals.get_signals_response import GetSignalsResponse + + unchanged = {"unchanged": True, "wholesale_feed_version": "v1"} + proven: list[tuple[type[BaseModel], str, dict[str, Any]]] = [ + (GetSignalsResponse, "cache_scope", unchanged), + (GetProductsResponse, "cache_scope", unchanged), + (DeadlinePolicy, "business_days_only", {}), + ( + ReportingResource, + "manifest_version", + { + "kind": "manifest", + "resource_ref": "res_1", + "location": "https://example.test/m.json", + "manifest_sha256": "a" * 64, + "immutability": "immutable_location", + "expires_at": "2030-01-01T00:00:00Z", + }, + ), + ] + for model, name, document in proven: + label = f"{model.__name__}.{name}" + omitted = model.model_validate(document) + typed = model.model_validate({**document, name: _sent_value(model, name)}) + assert omitted.model_dump(mode="json", exclude_none=True) == typed.model_dump( + mode="json", exclude_none=True + ), f"{label}: the two documents differ, so sent-ness is not what this pair proves" + assert name in typed.model_dump( + mode="json", exclude_none=True + ), f"{label}: the class no longer emits the default, so this pair proves nothing" + + +@contextmanager +def pytest_raises_validation(model: type[BaseModel], document: dict[str, Any]) -> Any: + """``model`` refuses ``document``, named so the assertion reads in one line.""" + try: + model.model_validate(document) + except ValidationError: + yield + return + raise AssertionError(f"{model.__name__} accepted {document!r}, which its conjunct forbids") + + +def _a_non_failing_result(model: type[BaseModel]) -> dict[str, Any]: + """A ``result`` the class accepts whose ``kind`` is not the one that fires. + + Derived from the arms rather than written down, because the two repaired + responses have different result vocabularies and only ``failed`` is common + to both. + """ + from adcp.types._split_root import branch_members + + annotation = model.model_fields["result"].annotation + arms = branch_members(annotation) or get_args(annotation) + for arm in arms: + fields = getattr(arm, "model_fields", {}) + kind = fields.get("kind") + if kind is None or kind.default == "failed": + continue + document: dict[str, Any] = {"kind": kind.default} + schema = arm.model_json_schema(mode="validation") + try: + candidates = parity.instances(schema, _LocalDefs(schema), budget=8) + except Exception: + candidates = [] + for candidate in candidates: + if not isinstance(candidate, dict): + continue + merged = {**candidate, **document} + try: + model.model_validate({"adcp_version": "3.2", "result": merged}) + except ValidationError: + continue + return merged + raise AssertionError(f"{model.__name__} has no non-failing result arm to build") diff --git a/tests/test_root_constraint_emission.py b/tests/test_root_constraint_emission.py new file mode 100644 index 000000000..9f15d252f --- /dev/null +++ b/tests/test_root_constraint_emission.py @@ -0,0 +1,147 @@ +"""Generation aborts on a constraint keyword the grammar cannot compile. + +This is the class-level guarantee, exercised rather than assumed. After the +constraint reader became key-consuming there are two channels to a model weaker +than its schema, and they differ in kind. A keyword kind the grammar does not +know cannot produce a weaker model BECAUSE IT CANNOT PRODUCE A MODEL: +generation raises, and ``scripts/generate_types.py`` generates into a staging +tree and installs only on success, so nothing ships. A semantic slip inside a +kind the grammar does know is a different, named residual, bounded to the +vocabulary primitives and witnessed by the differential parity suite. + +So the test below is not "a bad document is refused". It is: the attempt to +generate a model from a schema this SDK cannot express FAILS, and names the +file, the JSON pointer and the keyword a maintainer has to act on. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts import post_generate_fixes as fixes +from scripts.schema_constraints import UnexpressibleKeywordError + +#: A generated module shaped like the ones the pass edits: one flat class over +#: the root's properties, on the hand-written base that declares no fields. +_MODULE = """# generated by datamodel-codegen: +# filename: thing.json + +from __future__ import annotations + +from adcp.types.base import AdCPBaseModel + + +class Thing(AdCPBaseModel): + a: str | None = None + b: str | None = None +""" + +_SCHEMA: dict[str, object] = { + "title": "Thing", + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "string"}}, + "anyOf": [{"required": ["a"]}, {"required": ["b"]}], +} + + +@pytest.fixture +def staged(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: + """A one-schema bundle and the one module generated from it.""" + schemas = tmp_path / "schemas" + output = tmp_path / "domains" + schemas.mkdir() + output.mkdir() + (output / "thing.py").write_text(_MODULE) + monkeypatch.setattr(fixes, "SCHEMA_DIR", schemas) + monkeypatch.setattr(fixes, "OUTPUT_DIR", output) + return schemas, output + + +def test_a_compilable_root_gets_its_validator(staged: tuple[Path, Path]) -> None: + """The green path, so the red one below is not red for a setup reason.""" + schemas, output = staged + (schemas / "thing.json").write_text(json.dumps(_SCHEMA)) + + fixes.enforce_root_constraints() + + emitted = (output / "thing.py").read_text() + assert f"def {fixes.ROOT_CONSTRAINT_VALIDATOR}" in emitted + # ``sent`` rather than ``supplied``: the fixture declares ``a`` as a plain + # ``string``, so the schema forbids null there and the client drops a + # null-valued key on the way out. + assert "_c.sent(self, 'a')" in emitted + assert "at least one of these field groups" in emitted + + +def test_a_keyword_the_grammar_cannot_compile_aborts_generation( + staged: tuple[Path, Path], +) -> None: + """No model ships, and the refusal names what a maintainer must act on. + + ``unevaluatedProperties`` is a real 2019-09 keyword with real validation + semantics and no production in the grammar, which is exactly the shape of + the hazard: a future bundle adding a keyword kind this SDK cannot express. + """ + schemas, output = staged + before = (output / "thing.py").read_text() + (schemas / "thing.json").write_text( + json.dumps( + { + **_SCHEMA, + "anyOf": [ + {"required": ["a"], "unevaluatedProperties": False}, + {"required": ["b"]}, + ], + } + ) + ) + + with pytest.raises(UnexpressibleKeywordError) as raised: + fixes.enforce_root_constraints() + + assert raised.value.schema_file == "thing.json" + assert raised.value.pointer == "anyOf/0" + assert raised.value.keyword == "unevaluatedProperties" + assert ( + output / "thing.py" + ).read_text() == before, ( + "the module was edited before the refusal, so a weaker model could ship" + ) + + +def test_the_abort_is_not_caught_between_the_pass_and_the_install() -> None: + """A skip lane would have to exist somewhere; this is where it would be. + + ``post_generate_fixes.main`` calls each fix in a bare loop, so a refusal + becomes that process's non-zero exit. ``generate_types.py`` runs it as a + subprocess, checks the return code, and installs the staged tree only + afterwards -- so a refusal stops generation with the previous tree in place. + Asserted on the source because what is being asserted is the ABSENCE of a + handler and the ORDER of two statements, and the alternative is a + ten-minute regeneration per assertion. + """ + import ast + import inspect + + loop = ast.parse(inspect.getsource(fixes.main)) + assert not [node for node in ast.walk(loop) if isinstance(node, ast.Try)], ( + "post_generate_fixes.main wraps the fix pipeline in a try, which is where " + "a refusal would become a skip" + ) + + generating = inspect.getsource(_generate_module().main) + checked = generating.index("result.returncode != 0") + installed = generating.index("_install_generated_artifacts(") + assert checked < installed, ( + "generate_types installs the staged tree before checking whether the " + "post-generation pass refused" + ) + + +def _generate_module() -> object: + from scripts import generate_types + + return generate_types diff --git a/tests/test_root_document_capture.py b/tests/test_root_document_capture.py new file mode 100644 index 000000000..b4d88119b --- /dev/null +++ b/tests/test_root_document_capture.py @@ -0,0 +1,132 @@ +"""The root-document capture instrument, graded on the one claim it makes. + +``tests/capture_root_documents.py`` observes root constraint evaluation through +PEP 669 instead of rebinding the vocabulary's primitives, and every artifact it +writes carries ``instrument.subject_unchanged``. These tests exist because that +field is a SELF-ATTESTATION: the mechanism it replaced mutated the module under +test and still showed zero outcome transitions across 198 items once +``functools.wraps`` restored the one property anything asserted on, so a claim +of this shape is worth exactly what it costs to make it fail. +:func:`test_the_subject_verdict_loses_on_a_wraps_transparent_rebind` is that +cost. +""" + +from __future__ import annotations + +import functools +import json +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest + +# The instrument below observes through PEP 669 and REFUSES to import without +# ``sys.monitoring`` -- deliberately, since the mechanism it replaced rebound the +# module under test. That refusal is right for a programmatic caller and wrong as +# a collection-time error on the 3.10/3.11 matrix legs, where the capability does +# not exist to be tested. Gate the module here rather than soften the refusal. +if not hasattr(sys, "monitoring"): # pragma: no cover - CPython < 3.12 + pytest.skip( + "the root-document capture instrument needs PEP 669 (CPython 3.12+)", + allow_module_level=True, + ) + +# Through the package, which is the spelling the other constraint tests use and +# the only one that is order-independent: importing adcp.types.canonical_creative +# before the media_buy domain is a circular import. +from adcp.types import CreateMediaBuyRequest, GetReportingStatusRequest, _conditions +from tests import capture_root_documents as capture + +#: A document each capture point sees, and the point it exercises. +#: +#: The flat one passes field validation, which is what lets the compiled +#: ``model_validator`` run at all; the split one satisfies no branch, so it is +#: refused -- and the split capture point still records it, because it reads the +#: document at the union wrapper's ENTRY rather than from its outcome. +_FLAT_DOCUMENT = {"account": {"account_id": "account-1"}, "view": "periods"} +_SPLIT_DOCUMENT = {"totally": "wrong"} + + +@pytest.fixture +def armed(tmp_path: Path) -> Iterator[Path]: + """The instrument, armed for one test and released afterwards. + + Importing the module arms nothing on purpose, so a test can grade the + instrument without instrumenting the rest of the session. + """ + artifact = tmp_path / "rootdocs.json" + capture.install(artifact) + try: + yield artifact + finally: + capture.uninstall() + + +def test_both_capture_points_record_their_document(armed: Path) -> None: + """A flat root and a split root each land in the artifact, and the subject is intact. + + Both points in one test, because a capture mechanism that sees one of them + is the defect this instrument's predecessor had: hooking only the union + wrapper covered 11 roots of 103. + """ + GetReportingStatusRequest.model_validate(_FLAT_DOCUMENT) + with pytest.raises(Exception, match="at least one of these field groups"): + CreateMediaBuyRequest.model_validate(_SPLIT_DOCUMENT) + + captured = {record["source"]: record for record in capture.records()} + assert captured.keys() >= { + "flat", + "split", + }, f"a capture point recorded nothing: got {sorted(captured)}" + assert captured["flat"]["document"] == _FLAT_DOCUMENT + assert captured["split"]["document"] == _SPLIT_DOCUMENT + assert captured["split"]["names"], "a split record names the root's branch classes" + + report = capture.instrument_report() + assert report["subject_unchanged"], report["subject_changes"] + assert (report["frame_mismatches"], report["callback_faults"]) == (0, 0), report + + capture.flush() + artifact = json.loads(armed.read_text()) + assert artifact["instrument"]["subject_unchanged"] is True + assert artifact["documents"], "the artifact carries the documents, not just the verdict" + assert artifact["by_source"]["flat"] and artifact["by_source"]["split"] + + +def test_the_subject_verdict_loses_on_a_wraps_transparent_rebind(armed: Path) -> None: + """Rebinding ONE primitive must flip ``subject_unchanged`` to False. + + The fire probe. ``functools.wraps`` copies ``__module__`` and ``__name__``, + which is every property ``tests/test_conditions_vocabulary.py`` reads, so a + wrapped rebind is invisible there and was invisible to an outcome comparison + over 198 items. The verdict therefore compares IDENTITY as well, and this is + the test that would fail if it stopped doing so. + """ + assert capture.instrument_report()["subject_unchanged"], "armed, nothing rebound yet" + + real = _conditions.sent + + @functools.wraps(real) + def impostor(*args: object, **kwargs: object) -> object: + return real(*args, **kwargs) + + assert impostor.__module__ == real.__module__, "the probe must be wraps-transparent" + assert impostor.__name__ == real.__name__ + _conditions.sent = impostor # type: ignore[assignment] + try: + report = capture.instrument_report() + finally: + _conditions.sent = real + assert ( + report["subject_unchanged"] is False + ), "a wraps-transparent rebind left the verdict True, so the verdict grades nothing" + assert any("sent:" in change for change in report["subject_changes"]), report["subject_changes"] + assert capture.instrument_report()["subject_unchanged"], "restored" + + +def test_an_unnamed_artifact_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + """No destination means no run: a default path overwrites another lane's corpus.""" + monkeypatch.delenv("ROOT_DOCUMENT_CAPTURE", raising=False) + with pytest.raises(pytest.UsageError, match="ROOT_DOCUMENT_CAPTURE is unset"): + capture.pytest_configure(config=None) diff --git a/tests/test_schema_constraints.py b/tests/test_schema_constraints.py new file mode 100644 index 000000000..38dd6b0b6 --- /dev/null +++ b/tests/test_schema_constraints.py @@ -0,0 +1,727 @@ +"""The constraint reader's grammar, and its closure over the pinned bundle. + +Two obligations live here and nothing else does. + +The first is per-production: for every keyword the grammar claims a production +for, a constructed node carrying that keyword compiles to the node the grammar +names. ``_PRODUCTION_FIXTURES`` is the whole map, and +:func:`test_the_fixture_map_covers_exactly_the_claimed_grammar` makes a +production added without a fixture and a claim made without a production both +fail, so neither half can drift. + +The second is closure: every root constraint node in the pinned bundle parses +with zero unaccounted keys. That is §2's keyword census turned into a guard -- +it re-derives the accounting on every run instead of citing a measurement, so a +bundle sync that introduces a keyword kind the grammar does not know turns this +red rather than quietly widening the models. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest + +from scripts.schema_constraints import ( + COMPILED_KEYWORDS, + DECLARED_BEHAVIORAL_KEY, + NON_ASSERTING_KEYS, + AllOf, + AnyOf, + Cond, + DeclaredShape, + DependentRequired, + EveryItem, + IfThen, + KeysSatisfy, + MatchingKeysSatisfy, + NoOtherProps, + Not, + OneOf, + OtherPropsSatisfy, + PropCount, + PropSatisfies, + SomeItem, + Supplied, + Undeclarable, + UnexpressibleKeywordError, + UnplaceableConstraintError, + ValueBounds, + ValueConst, + ValueEnum, + ValueItems, + ValueLen, + ValuePattern, + ValueType, + ValueUnique, + bundle_document_resolver, + read_root_constraints, + root_carries_constraints, +) + + +def _bundle_dir() -> Path: + from scripts.post_generate_fixes import SCHEMA_DIR + + return SCHEMA_DIR + + +# --------------------------------------------------------------------------- # +# Per-production: one constructed node each +# --------------------------------------------------------------------------- # + + +def _read(root: dict[str, Any], **kwargs: Any) -> Any: + # ``_branch_root``'s branches are overlays on a flat root, which is what + # ``branches_render_as_classes=False`` says: one class over the merged + # properties, so every branch keyword is constraint content. + kwargs.setdefault("branches_render_as_classes", False) + return read_root_constraints(root, DeclaredShape.from_schema(root), **kwargs) + + +def _only_branch_condition(root: dict[str, Any], **kwargs: Any) -> Cond: + """The condition of a root whose single ``anyOf`` branch carries the keyword.""" + constraints = _read(root, **kwargs) + assert len(constraints.branches) == 1, root + return constraints.branches[0].condition + + +def _branch_root( + branch: dict[str, Any], properties: dict[str, Any] | None = None +) -> dict[str, Any]: + """A root declaring ``properties`` whose one overlay branch is ``branch``. + + The branch declares no new names, so it is an overlay: its keywords are + constraint content rather than a declaration the generator renders. + """ + return { + "title": "Thing", + "type": "object", + "properties": properties if properties is not None else {"a": {}, "b": {}}, + "anyOf": [branch], + } + + +#: One constructed node per claimed production, and the Cond it must compile to. +#: The keys are exactly ``COMPILED_KEYWORDS``, enforced below. +_PRODUCTION_FIXTURES: dict[str, tuple[dict[str, Any], Cond]] = { + "required": (_branch_root({"required": ["a"]}), Supplied("a")), + "properties": ( + _branch_root({"properties": {"a": {"const": 1}}}), + PropSatisfies("a", ValueConst(1)), + ), + "not": (_branch_root({"not": {"required": ["a"]}}), Not(Supplied("a"))), + "allOf": ( + _branch_root({"allOf": [{"required": ["a"]}, {"required": ["b"]}]}), + AllOf((Supplied("a"), Supplied("b"))), + ), + "anyOf": ( + _branch_root({"anyOf": [{"required": ["a"]}, {"required": ["b"]}]}), + AnyOf((Supplied("a"), Supplied("b"))), + ), + "oneOf": ( + _branch_root({"oneOf": [{"required": ["a"]}, {"required": ["b"]}]}), + OneOf((Supplied("a"), Supplied("b"))), + ), + "if": ( + _branch_root({"if": {"required": ["a"]}, "then": {"required": ["b"]}}), + IfThen(Supplied("a"), Supplied("b"), None), + ), + "then": ( + # A ``then`` with no sibling ``if`` asserts nothing, which is JSON + # Schema's own reading; the production is what accounts for the key. + _branch_root({"then": {"required": ["a"]}, "required": ["b"]}), + Supplied("b"), + ), + "else": ( + _branch_root( + { + "if": {"required": ["a"]}, + "then": {"required": ["b"]}, + "else": {"not": {"required": ["b"]}}, + } + ), + IfThen(Supplied("a"), Supplied("b"), Not(Supplied("b"))), + ), + "dependencies": ( + _branch_root({"dependencies": {"a": ["b"]}}), + DependentRequired("a", ("b",)), + ), + "dependentRequired": ( + _branch_root({"dependentRequired": {"a": ["b"]}}), + DependentRequired("a", ("b",)), + ), + "dependentSchemas": ( + _branch_root({"dependentSchemas": {"a": {"required": ["b"]}}}), + IfThen(Supplied("a"), Supplied("b", nullable=True), None), + ), + "const": ( + _branch_root({"properties": {"a": {"const": "x"}}}), + PropSatisfies("a", ValueConst("x")), + ), + "enum": ( + _branch_root({"properties": {"a": {"enum": ["x", "y"]}}}), + PropSatisfies("a", ValueEnum(("x", "y"))), + ), + "type": ( + _branch_root({"properties": {"a": {"type": "string"}}}), + PropSatisfies("a", ValueType(("string",))), + ), + "pattern": ( + _branch_root({"properties": {"a": {"pattern": "^x"}}}), + PropSatisfies("a", ValuePattern("^x")), + ), + "minLength": ( + _branch_root({"properties": {"a": {"minLength": 1}}}), + PropSatisfies("a", ValueLen(minimum=1)), + ), + "maxLength": ( + _branch_root({"properties": {"a": {"maxLength": 9}}}), + PropSatisfies("a", ValueLen(maximum=9)), + ), + "minimum": ( + _branch_root({"properties": {"a": {"minimum": 0}}}), + PropSatisfies("a", ValueBounds(ge=0)), + ), + "maximum": ( + _branch_root({"properties": {"a": {"maximum": 100}}}), + PropSatisfies("a", ValueBounds(le=100)), + ), + "exclusiveMinimum": ( + _branch_root({"properties": {"a": {"exclusiveMinimum": 0}}}), + PropSatisfies("a", ValueBounds(gt=0)), + ), + "exclusiveMaximum": ( + _branch_root({"properties": {"a": {"exclusiveMaximum": 1}}}), + PropSatisfies("a", ValueBounds(lt=1)), + ), + "minItems": ( + _branch_root({"properties": {"a": {"minItems": 1}}}), + PropSatisfies("a", ValueItems(minimum=1)), + ), + "maxItems": ( + _branch_root({"properties": {"a": {"maxItems": 2}}}), + PropSatisfies("a", ValueItems(maximum=2)), + ), + "uniqueItems": ( + _branch_root({"properties": {"a": {"uniqueItems": True}}}), + PropSatisfies("a", ValueUnique()), + ), + "items": ( + _branch_root({"properties": {"a": {"items": {"required": ["b"]}}}}), + PropSatisfies("a", EveryItem(Supplied("b", nullable=True))), + ), + "contains": ( + _branch_root({"properties": {"a": {"contains": {"required": ["b"]}}}}), + PropSatisfies("a", SomeItem(Supplied("b", nullable=True))), + ), + "propertyNames": ( + _branch_root({"properties": {"a": {"propertyNames": {"pattern": "^x"}}}}), + PropSatisfies("a", KeysSatisfy(ValuePattern("^x"))), + ), + "patternProperties": ( + _branch_root({"properties": {"a": {"patternProperties": {"^x": {"minimum": 1}}}}}), + PropSatisfies("a", MatchingKeysSatisfy("^x", ValueBounds(ge=1))), + ), + "minProperties": ( + _branch_root({"properties": {"a": {"minProperties": 1}}}), + PropSatisfies("a", PropCount(minimum=1)), + ), + "maxProperties": ( + _branch_root({"properties": {"a": {"maxProperties": 3}}}), + PropSatisfies("a", PropCount(maximum=3)), + ), + "additionalProperties": ( + _branch_root( + {"properties": {"a": {"properties": {"b": {}}, "additionalProperties": False}}} + ), + PropSatisfies("a", NoOtherProps(("b",))), + ), + "$ref": ( + { + "title": "Thing", + "type": "object", + "properties": {"a": {"type": "object"}}, + "$defs": {"narrowed": {"required": ["b"]}}, + "anyOf": [{"properties": {"a": {"$ref": "#/$defs/narrowed"}}}], + }, + PropSatisfies("a", Supplied("b", nullable=True)), + ), +} + + +def test_the_fixture_map_covers_exactly_the_claimed_grammar() -> None: + """A production without a fixture, or a claim without a production, fails. + + The map below is the per-production obligation set. Deriving the + parametrization from it and the claim from ``COMPILED_KEYWORDS`` + independently is what makes the two halves unable to drift apart. + """ + assert set(_PRODUCTION_FIXTURES) == set(COMPILED_KEYWORDS) + + +@pytest.mark.parametrize("keyword", sorted(_PRODUCTION_FIXTURES), ids=str) +def test_each_grammar_production_compiles_its_keyword(keyword: str) -> None: + """One constructed node per production, compiled to the node it names.""" + root, expected = _PRODUCTION_FIXTURES[keyword] + assert _only_branch_condition(root) == expected + + +def test_additional_properties_as_a_subschema_grades_the_unnamed_members() -> None: + """The map form, which is how the bundle spells ``dict[str, <enum>]``. + + The ``false`` form is in the fixture map above; this is the production's + other branch, and a map type has no sibling ``properties`` to exempt. + """ + root = _branch_root({"properties": {"a": {"additionalProperties": {"enum": ["pass", "fail"]}}}}) + + assert _only_branch_condition(root) == PropSatisfies( + "a", OtherPropsSatisfy((), ValueEnum(("pass", "fail"))) + ) + + +def test_presence_is_membership_not_a_null_check() -> None: + """``required`` compiles to supplied-ness, which is what the schema asks. + + Measured: an ``is None`` guard is only correct when the field's default + happens to be ``None``. A non-None default makes it false for an absent + field and the check over-refuses, and an unguarded attribute read raises on + absence and on an explicit null alike. ``Supplied`` is the grammar's only + presence node, so no compiled predicate can spell presence the other ways. + """ + assert _only_branch_condition(_branch_root({"required": ["a"]})) == Supplied("a") + + +# --------------------------------------------------------------------------- # +# The closure rule +# --------------------------------------------------------------------------- # + + +def test_a_keyword_outside_every_named_rule_aborts_naming_file_pointer_keyword() -> None: + """The refusal carries what a maintainer needs to grow the grammar.""" + root = _branch_root({"unevaluatedProperties": False}) + + with pytest.raises(UnexpressibleKeywordError) as raised: + _read(root, schema_file="made-up/thing.json") + + assert raised.value.schema_file == "made-up/thing.json" + assert raised.value.pointer == "anyOf/0" + assert raised.value.keyword == "unevaluatedProperties" + + +def test_a_constraint_naming_a_field_the_schema_declares_but_the_class_does_not_refuses() -> None: + """A dropped field surfaces loudly instead of placing the constraint nowhere.""" + root = _branch_root({"required": ["b"]}) + declared_without_b = DeclaredShape(fields={"a": "a"}, nested={}) + + with pytest.raises(UnplaceableConstraintError) as raised: + read_root_constraints( + root, + declared_without_b, + branches_render_as_classes=False, + schema_file="media-buy/thing.json", + ) + + assert raised.value.name == "b" + + +def test_required_reads_presence_on_the_document_that_is_sent() -> None: + """Nullability is the axis membership alone cannot carry. + + ``required`` asks whether the key is on the document. At a position the + schema marks nullable an explicit null is the caller's value and the client + keeps the key, so membership is the whole question. Where null is + forbidden, ``exclude_none`` drops the key and ``preserve_explicit_nulls`` + restores only nullable paths, so the key is absent from what is sent -- + ``control-media-buy-request``'s ``canceled`` is ``Literal[True]`` and + ``canceled=None`` beside a real mutation is dropped on the way out. + + Derived from the declaration at the position, never defaulted: the reader + reads the node it is already walking. + """ + root = { + "title": "Thing", + "type": "object", + "properties": { + "canceled": {"const": True}, + "daily_budget_cap": {"type": ["number", "null"]}, + }, + "anyOf": [{"required": ["canceled", "daily_budget_cap"]}], + } + + assert _only_branch_condition(root) == AllOf( + (Supplied("canceled", nullable=False), Supplied("daily_budget_cap", nullable=True)) + ) + + +def test_a_required_name_a_closed_root_never_declares_is_statically_false() -> None: + """An AdCP root that forbids a key no branch declares is not a dropped field. + + ``control-media-buy-request`` forbids ``creatives`` behind + ``additionalProperties: false``, which the generator renders as + ``extra='forbid'``: the document may not carry the key at all, so a model + has no field for it and can be given none. That is decidable, and deciding + it is what keeps the field-drop refusal above meaningful rather than firing + on every such root. + """ + root = { + "title": "Thing", + "type": "object", + "properties": {"a": {}}, + "additionalProperties": False, + "not": {"anyOf": [{"required": ["creatives"]}]}, + } + + assert _read(root).conjuncts == (Not(AnyOf((Undeclarable("creatives"),))),) + + +def test_a_required_name_an_open_root_never_declares_is_read_off_the_document() -> None: + """Under ``additionalProperties: true`` the key is carryable, so it is read. + + ``get-reporting-status-response`` is open and its nested branch grades + ``failure_kind``, a property only that branch declares and the generator + renders no field for. Pydantic files such a key under extras, puts it in + ``model_fields_set`` and resolves it through ``getattr``, so the predicate + is ordinary presence under the wire name. Calling it statically false here + would silently drop a constraint the document can violate. + """ + root = { + "title": "Thing", + "type": "object", + "properties": {"a": {}}, + "additionalProperties": True, + "not": {"anyOf": [{"required": ["failure_kind"]}]}, + } + + assert _read(root).conjuncts == (Not(AnyOf((Supplied("failure_kind", nullable=True),))),) + + +def test_a_keyword_equal_to_the_declaration_is_a_proven_restatement() -> None: + """Equality is checked, and a narrowing is not equality. + + ``packages.items.allOf[0].$ref`` to ``package-request.json`` restates the + declared item type; the sibling member's ``required`` narrows it. The first + drops out of the compiled tree and the second does not. + """ + root = { + "title": "Thing", + "type": "object", + "properties": {"packages": {"items": {"$ref": "core/package-request.json"}}}, + "anyOf": [ + { + "properties": { + "packages": { + "items": { + "allOf": [ + {"$ref": "core/package-request.json"}, + {"required": ["budget"]}, + ] + } + } + } + } + ], + } + + assert _only_branch_condition(root) == PropSatisfies( + "packages", EveryItem(Supplied("budget", nullable=True)) + ) + + +def test_a_narrowing_of_the_declared_type_compiles_rather_than_restating() -> None: + root = { + "title": "Thing", + "type": "object", + "properties": {"a": {"type": "string"}}, + "anyOf": [{"properties": {"a": {"type": "string", "const": "x"}}}], + } + + assert _only_branch_condition(root) == PropSatisfies("a", ValueConst("x")) + + +def test_the_declared_behavioral_key_is_classified_not_compiled() -> None: + """``x-adcp-validation`` is a contract citation, not a tolerance entry. + + ``docs/validation-contract.md`` puts these rules outside the structural + models' promise. The parser records that an branch carries them -- which is + what lets ``oneOf`` cardinality refuse rather than approximate -- and there + is no per-schema list anywhere. + """ + root = _branch_root({"required": ["a"], DECLARED_BEHAVIORAL_KEY: {"verifier_constraints": {}}}) + + constraints = _read(root) + + assert constraints.declares_behavioral_rules + assert constraints.branches[0].declares_behavioral_rules + assert constraints.branches[0].condition == Supplied("a") + + +def test_an_branches_own_keywords_are_proven_against_the_root_not_against_itself() -> None: + """A flat root's branch states constraint content, whatever it introduces. + + ``list-account-changes-response`` is one class over two branches, each branch + introducing ``status`` with its own ``const`` and its own ``required``. + Reading the branch as its own class proves each of those a restatement of + itself, and the measured result was a model enforcing nothing at all from + either branch. + """ + root = { + "title": "Thing", + "type": "object", + "properties": {"a": {}}, + "oneOf": [ + {"required": ["status", "a"], "properties": {"status": {"const": "completed"}}}, + {"required": ["status"], "properties": {"status": {"const": "failed"}}}, + ], + } + + constraints = read_root_constraints( + root, + DeclaredShape(fields={"a": "a", "status": "status"}), + branches_render_as_classes=False, + ) + + assert constraints.branches[0].condition == AllOf( + ( + PropSatisfies("status", ValueConst("completed")), + AllOf((Supplied("status", nullable=True), Supplied("a"))), + ) + ) + assert constraints.branches[1].condition == AllOf( + (PropSatisfies("status", ValueConst("failed")), Supplied("status", nullable=True)) + ) + + +def test_a_property_named_only_in_a_negated_position_is_not_a_dropped_field() -> None: + """``not`` declares nothing, so a name reachable only through one is not one. + + ``get-creative-features-terminal-success``'s root ``not`` forbids + ``status: submitted`` and the schema declares ``status`` nowhere else. + Counting the negated position as a declaration turned a compilable absence + ban into a field-drop refusal that aborted generation. + """ + root = { + "title": "Thing", + "type": "object", + "properties": {"a": {}}, + "not": { + "anyOf": [{"properties": {"status": {"const": "submitted"}}, "required": ["status"]}] + }, + } + + constraints = read_root_constraints( + root, DeclaredShape(fields={"a": "a"}), branches_render_as_classes=False + ) + + assert constraints.conjuncts == ( + Not( + AnyOf( + ( + AllOf( + ( + PropSatisfies("status", ValueConst("submitted")), + Supplied("status", nullable=True), + ) + ), + ) + ) + ), + ) + + +def test_the_three_classification_sets_are_disjoint() -> None: + """A key classified two ways would make the accounting ambiguous.""" + assert not (COMPILED_KEYWORDS & NON_ASSERTING_KEYS) + assert DECLARED_BEHAVIORAL_KEY not in COMPILED_KEYWORDS + assert DECLARED_BEHAVIORAL_KEY not in NON_ASSERTING_KEYS + + +# --------------------------------------------------------------------------- # +# Bundle closure: §2's census, re-derived every run +# --------------------------------------------------------------------------- # + +#: Positions whose subschema grades the same object as its parent, plus the +#: containers a constraint descends through. Walked here independently of the +#: parser, so the census is a second reading rather than the parser's own. +_SUBSCHEMA = ( + "not", + "if", + "then", + "else", + "items", + "contains", + "propertyNames", + "additionalProperties", + "unevaluatedProperties", +) +_SUBSCHEMA_LISTS = ("anyOf", "oneOf", "allOf", "prefixItems") +_SUBSCHEMA_MAPS = ( + "properties", + "patternProperties", + "dependencies", + "dependentSchemas", + "$defs", + "definitions", +) + + +def _constrained_roots() -> list[tuple[str, dict[str, Any]]]: + """Every pinned schema whose root states more than its merged properties.""" + bundle = _bundle_dir() + roots: list[tuple[str, dict[str, Any]]] = [] + for path in sorted(bundle.rglob("*.json")): + if "bundled" in path.parts: + continue + try: + schema = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if isinstance(schema, dict) and root_carries_constraints(schema): + roots.append((path.relative_to(bundle).as_posix(), schema)) + return roots + + +def _keys_in_constraint_content(schema: dict[str, Any]) -> Iterator[str]: + """Every key the root's constraint content carries, at any depth.""" + + def walk(node: Any) -> Iterator[str]: + if not isinstance(node, dict): + return + yield from node + for key in _SUBSCHEMA: + if isinstance(node.get(key), dict): + yield from walk(node[key]) + for key in _SUBSCHEMA_LISTS: + if isinstance(node.get(key), list): + for member in node[key]: + yield from walk(member) + for key in _SUBSCHEMA_MAPS: + if isinstance(node.get(key), dict): + for member in node[key].values(): + yield from walk(member) + + for combinator in ("anyOf", "oneOf"): + branches = schema.get(combinator) + if isinstance(branches, list) and branches: + for branch in branches: + yield from walk(branch) + break + for keyword in ("if", "then", "else", "not"): + if keyword in schema: + yield keyword + yield from walk(schema[keyword]) + for keyword in ("dependencies", "dependentRequired"): + members = schema.get(keyword) + if isinstance(members, dict): + yield keyword + for member in members.values(): + yield from walk(member) + + +@pytest.fixture(scope="module") +def constrained_roots() -> list[tuple[str, dict[str, Any]]]: + return _constrained_roots() + + +def test_the_closure_population_is_the_whole_pinned_bundle( + constrained_roots: list[tuple[str, dict[str, Any]]], +) -> None: + """A derivation that selects nothing passes for free. + + §2 measured 250 roots with a combinator and 51 with a root ``not``, 23 with + ``if``, 19 with ``dependencies``, 8 with ``dependentRequired`` -- hundreds, + overlapping. The floor below is what distinguishes "everything parsed" from + "the walk found nothing to parse"; the exact count is re-derived rather + than asserted, because a bundle sync moves it legitimately. + """ + assert len(constrained_roots) > 250, ( + "the bundle walk found almost no root constraint content; the closure " + "rule below would then grade nothing" + ) + + +def test_every_root_constraint_node_in_the_bundle_parses_with_no_unaccounted_key( + constrained_roots: list[tuple[str, dict[str, Any]]], +) -> None: + """Closure: a keyword this SDK cannot express cannot reach a shipped model. + + Every refusal is collected rather than failing on the first, so a bundle + sync that introduces several kinds names all of them in one run. + """ + resolve = bundle_document_resolver(_bundle_dir()) + refusals: list[str] = [] + for name, schema in constrained_roots: + try: + read_root_constraints( + schema, + DeclaredShape.from_schema(schema), + # No generated tree is consulted here, so the shape's own + # reading applies: every branch that declares a property is read as + # its own class, which is what ``from_schema`` asserts by + # carrying every branch-declared name. This rule grades keyword + # accounting, not which shape got emitted. + branches_render_as_classes=True, + schema_file=name, + resolve_document=resolve, + ) + except (UnexpressibleKeywordError, UnplaceableConstraintError) as refusal: + refusals.append(str(refusal)) + + assert not refusals, "\n".join(refusals) + + +def test_every_key_the_bundle_carries_falls_under_a_named_rule( + constrained_roots: list[tuple[str, dict[str, Any]]], +) -> None: + """The census as a guard: the key set is accounted for, not sampled. + + This reads the bundle independently of the parser, so it grades the three + named sets against the real key population rather than against the walk + the parser happens to take. + """ + accounted = COMPILED_KEYWORDS | NON_ASSERTING_KEYS | {DECLARED_BEHAVIORAL_KEY} + found: set[str] = set() + for _name, schema in constrained_roots: + found.update(_keys_in_constraint_content(schema)) + + assert len(found) > 30, f"the census walk found only {sorted(found)}" + assert found <= accounted, sorted(found - accounted) + + +def test_the_readers_nullability_agrees_with_the_serializers_on_the_whole_bundle() -> None: + """Two copies of one question, necessarily separate, held in step by this. + + ``scripts/schema_constraints.permits_null`` decides what a compiled check + reads as present; ``adcp._null_clear`` decides what the client actually + sends. They cannot share code -- one is build-time under ``scripts/`` and + the other is runtime under ``src/`` -- so they are kept honest by agreeing + on every property node the pinned bundle carries. If they ever disagree, a + model refuses a document the client never sends, or accepts one it does. + """ + from adcp._null_clear import _permits_null as serializer_permits_null + from scripts.schema_constraints import permits_null + + bundle = _bundle_dir() + disagreements: list[str] = [] + checked = 0 + for path in sorted(bundle.rglob("*.json")): + if path.relative_to(bundle).parts[0] in ("mcp", "bundled"): + continue + try: + document = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + continue + for name, node in ( + (document.get("properties") or {}).items() if isinstance(document, dict) else () + ): + checked += 1 + mine = permits_null(node) + theirs = serializer_permits_null(node, document) + if mine is not theirs: + disagreements.append(f"{path.relative_to(bundle)}#{name}: {mine} vs {theirs}") + + assert checked > 500, f"the walk graded only {checked} property nodes" + assert not disagreements, disagreements[:10] diff --git a/tests/test_schema_partition.py b/tests/test_schema_partition.py new file mode 100644 index 000000000..e2e368e72 --- /dev/null +++ b/tests/test_schema_partition.py @@ -0,0 +1,334 @@ +"""The emit rule: where a root's branches partition, and where they only overlap. + +One prover answers two questions, and both are decided from ``Branch`` records +rather than from a list kept here. ``provably_disjoint`` asks whether any +document can satisfy two conditions at once; ``partitions_by_requiredness`` +asks whether a whole root's branches are therefore distinct types. A root that +partitions is emitted as one class per branch plus a union, because the class a +parsed document comes back as is then schema information. A root that does not +is emitted as one class and a compiled check, because minting a class there +would make branch ORDER an observable class identity -- a channel the flat +class did not have. + +The prover is sound and deliberately incomplete: every node kind it has no +literal for weakens the condition, and a refutation of a weaker conjunction +refutes the stronger one, so an unmodelled kind can only cost a proof. The +measured consequence over the pinned bundle is graded by +:func:`test_the_pinned_bundle_splits_where_the_measurement_says`. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from scripts.schema_constraints import ( + AnyOf, + Branch, + DeclaredShape, + Not, + PropSatisfies, + RootConstraints, + Supplied, + ValueConst, + branch_conditions, + partitions_by_requiredness, + provably_disjoint, + read_root_constraints, + root_carries_constraints, +) + + +def _read(root: dict[str, Any], shape: DeclaredShape | None = None) -> RootConstraints: + return read_root_constraints( + root, + shape if shape is not None else DeclaredShape.from_schema(root), + branches_render_as_classes=False, + ) + + +# --------------------------------------------------------------------------- # +# What the prover decides +# --------------------------------------------------------------------------- # + + +def test_an_branch_requiring_what_another_bans_is_disjoint() -> None: + """``create-media-buy``'s proposal branch bans ``packages``; two branches require it.""" + assert provably_disjoint( + Supplied("packages"), + Not(AnyOf((Supplied("packages"), Supplied("budget_allocation")))), + ) + + +def test_two_branches_pinning_one_property_to_different_values_are_disjoint() -> None: + """The ``status`` discriminator, and ``budget_allocation.mode`` one level down. + + The value literal only ever reaches a clause alongside the presence literal + for its own path, which is what makes an empty value intersection a + refutation rather than a guess about an absent member. + """ + completed = PropSatisfies("status", ValueConst("completed")) + failed = PropSatisfies("status", ValueConst("failed")) + + assert not provably_disjoint( + completed, failed + ), "neither branch requires status, so a document omitting it satisfies both" + assert provably_disjoint(_and(Supplied("status"), completed), _and(Supplied("status"), failed)) + + +def test_an_overlapping_cardinality_pair_is_not_disjoint() -> None: + """``control-media-buy``'s branches: require one field, permit the others.""" + assert not provably_disjoint(Supplied("paused"), Supplied("name")) + + +def _and(*operands: Any) -> Any: + from scripts.schema_constraints import AllOf + + return AllOf(operands) + + +# --------------------------------------------------------------------------- # +# What the emit rule decides +# --------------------------------------------------------------------------- # + + +def test_a_root_whose_branches_are_mutually_exclusive_partitions() -> None: + root = { + "title": "Thing", + "type": "object", + "properties": {"a": {}, "b": {}}, + "anyOf": [ + {"required": ["a"], "not": {"required": ["b"]}}, + {"required": ["b"], "not": {"required": ["a"]}}, + ], + } + + assert partitions_by_requiredness(_read(root)) + + +def test_the_at_least_one_of_idiom_does_not_partition() -> None: + """Twelve branches requiring one field each and permitting the rest is one shape. + + ``control-media-buy-request`` spells "name at least one field to change" + this way, because JSON Schema has no ``atLeastOneOf``. A document carrying + ``paused`` and ``name`` satisfies two branches, so the class a left-to-right + union returns is decided by the order the branches appear in a JSON file. + """ + root = { + "title": "Thing", + "type": "object", + "properties": {"a": {}, "b": {}}, + "anyOf": [{"required": ["a"]}, {"required": ["b"]}], + } + + assert not partitions_by_requiredness(_read(root)) + + +def test_an_branch_with_no_required_does_not_partition() -> None: + """Requiredness is what the rule partitions BY, and disjointness is not enough. + + Two reasons, and the second is the one that is easy to lose. Without + ``required`` an branch grades only members that are present, so + ``postal-country-system``'s ten country branches are all satisfied by a + document carrying no ``country`` and a left-to-right union would hand back + the US class for it -- the first root below. And even a pair the prover DOES + separate is refused when neither branch requires anything: the required set is + the branch's identity, the thing rung 3 of the naming rule spells, and an branch + with none names no shape. + """ + vacuous = { + "title": "Thing", + "type": "object", + "properties": {"country": {}, "system": {}}, + "anyOf": [ + {"properties": {"country": {"const": "US"}}}, + {"properties": {"country": {"const": "GB"}}}, + ], + } + + assert not partitions_by_requiredness(_read(vacuous)) + + disjoint_but_unnamed = RootConstraints( + combinator="anyOf", + branches=( + Branch( + pointer="anyOf/0", + title=None, + discriminator=None, + required=("a",), + declares_fields=False, + condition=Supplied("a"), + ), + Branch( + pointer="anyOf/1", + title=None, + discriminator=None, + required=(), + declares_fields=False, + condition=Not(Supplied("a")), + ), + ), + conjuncts=(), + ) + + assert provably_disjoint( + disjoint_but_unnamed.branches[0].condition, disjoint_but_unnamed.branches[1].condition + ) + assert not partitions_by_requiredness(disjoint_but_unnamed) + + +def test_a_root_conjunct_is_what_separates_two_otherwise_overlapping_branches() -> None: + """``webhook-challenge-response`` bans ``challenge`` and ``token`` together. + + The ban sits beside the combinator, not inside either branch, and each branch + requires one of the two names. Proving the branches disjoint without it would + be wrong, and emitting classes without distributing it would admit a + document carrying both. + """ + branches = [{"required": ["challenge"]}, {"required": ["token"]}] + overlapping = { + "title": "Thing", + "type": "object", + "properties": {"challenge": {}, "token": {}}, + "anyOf": branches, + } + separated = dict(overlapping, **{"not": {"required": ["challenge", "token"]}}) + + assert not partitions_by_requiredness(_read(overlapping)) + assert partitions_by_requiredness(_read(separated)) + assert len(branch_conditions(_read(separated))[0].operands) == 2 + + +# --------------------------------------------------------------------------- # +# The measured split over the pinned bundle +# --------------------------------------------------------------------------- # + + +def _flat_combinator_roots() -> list[tuple[str, RootConstraints]]: + """Every flat-root combinator schema in the pinned bundle, read once. + + Membership is derived: a schema whose root carries a combinator and whose + generated module holds ONE class over the root's declared properties, which + is what ``flat_root_shape`` answers. The alternative -- a list of file names + kept here -- would stop grading a schema the next bundle adds. + """ + from scripts.post_generate_fixes import SCHEMA_DIR, flat_root_shape + from scripts.schema_constraints import bundle_document_resolver + + resolve = bundle_document_resolver(SCHEMA_DIR) + roots: list[tuple[str, RootConstraints]] = [] + for schema_file in sorted(SCHEMA_DIR.rglob("*.json")): + relative = schema_file.relative_to(SCHEMA_DIR) + if relative.parts[0] in ("mcp", "bundled"): + continue + try: + schema = json.loads(schema_file.read_text()) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(schema, dict) or not root_carries_constraints(schema): + continue + shape = flat_root_shape(schema, relative) + if shape is None: + continue + constraints = read_root_constraints( + schema, + shape, + branches_render_as_classes=False, + schema_file=str(relative), + resolve_document=resolve, + ) + if constraints.combinator: + roots.append((str(relative), constraints)) + return roots + + +@pytest.fixture(scope="module") +def flat_combinator_roots() -> list[tuple[str, RootConstraints]]: + return _flat_combinator_roots() + + +def test_the_population_is_not_empty( + flat_combinator_roots: list[tuple[str, RootConstraints]], +) -> None: + """A derivation that selects nothing passes for free.""" + assert len(flat_combinator_roots) > 30, ( + "the walk found almost no flat-root combinator schema; the split below " + "would then grade nothing" + ) + + +#: The flat-root combinator schemas of the pinned bundle whose branches partition, +#: so each branch is emitted as its own class behind a union alias. Written down +#: because this set decides which public names the SDK has -- and because it +#: is how the two roots that declare their properties ONLY inside their +#: branches were found. ``search-brands-response`` and +#: ``verify-brand-claim-request`` carry no root ``properties`` of their own; +#: the generator merges the branches' properties onto one class, and the +#: reader did not look there, so both went unsplit while this set said +#: eleven: a silent change to +#: it is a silent change to the surface. The counts move with a bundle sync and +#: are therefore re-derived rather than asserted. +PARTITIONING_ROOTS = frozenset( + { + "account/list-account-changes-response.json", + "brand/search-brands-response.json", + "brand/verify-brand-claim-request.json", + "core/audience-characteristic.json", + "core/catchment.json", + "core/creative-asset.json", + "core/creative-manifest.json", + "core/frequency-cap-impression-constraints.json", + "core/frequency-cap-interval-constraints.json", + "core/get-geo-place-resolution-request.json", + "core/webhook-challenge-response.json", + "media-buy/create-media-buy-request.json", + "media-buy/request-proposals-request.json", + } +) + + +def test_the_pinned_bundle_splits_where_the_measurement_says( + flat_combinator_roots: list[tuple[str, RootConstraints]], +) -> None: + """Both sides of the split are populated, and the partitioning set is named. + + A prover that proved everything, or nothing, would pass a rule that only + counted. So this asserts which schemas partition, and that the other side is + the larger one -- which is what the cardinality idiom predicts. + """ + partitioning = { + name + for name, constraints in flat_combinator_roots + if partitions_by_requiredness(constraints) + } + + assert partitioning == set(PARTITIONING_ROOTS) + assert ( + len(flat_combinator_roots) - len(partitioning) > 20 + ), "almost every root partitioned, which the cardinality idiom says is wrong" + + +def test_every_partitioning_one_of_is_pairwise_disjoint( + flat_combinator_roots: list[tuple[str, RootConstraints]], +) -> None: + """``oneOf`` is exactly-one and a union is at-least-one. + + Emitting a ``oneOf`` whose branches are not provably disjoint as a union would + ship anyOf-weaker semantics, so the emitter refuses with + ``UndisjointOneOfError``. This is the bundle's standing evidence that every + ``oneOf`` reaching the union path clears the stronger bar, independently of + the emit rule that selected it. + """ + graded = 0 + for name, constraints in flat_combinator_roots: + if constraints.combinator != "oneOf" or name not in PARTITIONING_ROOTS: + continue + conditions = branch_conditions(constraints) + graded += 1 + for index, left in enumerate(conditions): + for right in conditions[index + 1 :]: + assert provably_disjoint(left, right), name + + assert graded >= 5, f"only {graded} oneOf roots reached the union path" diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py index 90c8b75d3..c43721a70 100644 --- a/tests/test_schema_validation.py +++ b/tests/test_schema_validation.py @@ -56,9 +56,18 @@ def test_accepts_pydantic_model_and_validates_its_wire_serialization(self) -> No assert outcome.valid is True def test_pydantic_model_still_runs_canonical_cross_field_validation(self) -> None: - structurally_valid = GetSignalsRequest() + """``validate_request`` grades the document, not the model's word for it. - outcome = validate_request("get_signals", structurally_valid) + ``get-signals-request.json`` requires a selector outside wholesale mode, + and the empty request satisfies none. The generated model now refuses it + too -- that conditional used to be dropped -- so the document reaches + this function only through ``model_construct``, which is what the + function exists to be given: a model whose own validation was skipped + or whose schema the SDK has not yet caught up with. + """ + unvalidated = GetSignalsRequest.model_construct() + + outcome = validate_request("get_signals", unvalidated) assert outcome.valid is False assert any(issue.keyword == "anyOf" for issue in outcome.issues) @@ -225,7 +234,13 @@ def test_build_adcp_validation_error_payload(self) -> None: payload = build_adcp_validation_error_payload("create_media_buy", "response", issues) assert payload["code"] == "VALIDATION_ERROR" assert "/media_buy_id" in payload["message"] - assert payload["field"] == "/media_buy_id" + # core/error.json: `field` is JSONPath-lite, and when issues[] is + # present sellers MUST set it to issues[0].pointer TRANSLATED. This + # assertion read "/media_buy_id" and so pinned the untranslated + # pointer — the defect itself (salesagent-8qela.18). The pointer keeps + # its RFC 6901 spelling one line below: the two formats side by side + # are the dual-write the spec asks for. + assert payload["field"] == "media_buy_id" assert payload["details"]["tool"] == "create_media_buy" assert payload["details"]["side"] == "response" assert payload["details"]["issues"][0]["pointer"] == "/media_buy_id" diff --git a/tests/test_server_framework.py b/tests/test_server_framework.py index 828ce6622..f9266d7f3 100644 --- a/tests/test_server_framework.py +++ b/tests/test_server_framework.py @@ -321,17 +321,39 @@ async def handle_sync_plans(self, request, context=None): return SyncPlansResponse(plans=[]) async def handle_check_governance(self, request, context=None): + # governance/check-governance-response.json requires check_id, + # verdict and explanation, and does not declare `status` at all -- + # the 3.1 rename freed that key for the envelope task-status + # (salesagent-8qela.21). + # + # What the root allOf then requires depends on whether the document + # carries `check_type`, and this stub does not. In that branch an + # approved verdict owes plan_id (#/allOf/1/else/allOf/0/required) + # and expires_at (#/allOf/1/else/allOf/3/then/required), and + # governance_context is optional. It becomes required only once + # `check_type` is present (#/allOf/1/then/allOf/2/then/required). + # It is sent here anyway, so the stub stays valid if this response + # ever grows a check_type. Each schema_path above is one the + # bundled oracle reported for a document missing that field, not a + # reading of the JSON. + return CheckGovernanceResponse( check_id="chk_123", - status="approved", + verdict="approved", plan_id="plan_123", explanation="Approved", + expires_at="2026-12-01T00:00:00Z", + governance_context="ctx-chk-123", ) async def handle_report_plan_outcome(self, request, context=None): + # The same 3.1 rename, in the same stub: `status` became + # outcome_state here to free the top-level key for the envelope + # task-status. governance/report-plan-outcome-response.json requires + # outcome_id and outcome_state and declares no `status`. return ReportPlanOutcomeResponse( outcome_id="out_123", - status="accepted", + outcome_state="accepted", ) async def handle_get_plan_audit_logs(self, request, context=None): diff --git a/tests/test_simple_api.py b/tests/test_simple_api.py index 3e7369db0..b3f82a173 100644 --- a/tests/test_simple_api.py +++ b/tests/test_simple_api.py @@ -26,7 +26,12 @@ async def test_get_products_simple_api(): name="Test Product", description="A test product", ) - mock_response = GetProductsResponse.model_construct(products=[mock_product]) + # ``model_construct`` skips field validation but the TaskResult below + # revalidates, so the response still has to be one its schema accepts: + # ``cache_scope`` is required on every 3.1+ get_products response. + mock_response = GetProductsResponse.model_construct( + products=[mock_product], cache_scope="public" + ) mock_result = TaskResult[GetProductsResponse]( status=TaskStatus.COMPLETED, data=mock_response, success=True ) @@ -214,7 +219,21 @@ async def test_preview_creative_simple_api(): from adcp.types.legacy import LegacyFormatId as FormatId format_id = FormatId(agent_url="https://creative.example.com", id="banner_300x250") - creative_manifest = CreativeManifest.model_construct(format_id=format_id, assets={}) + # CONSTRUCTED, not model_construct'd. ``CreativeManifest`` is the base over + # core/creative-manifest.json's two branches, and + # ``PreviewCreativeRequest.creative_manifest`` is annotated with it: the + # field accepts a document either way round and a BRANCH instance, and + # refuses an instance of the LOOSE BASE -- which + # test_dispatch_instance_identity.py:: + # test_an_instance_of_the_loose_base_is_refused_by_its_own_name grades as + # refused over every dispatching root. + # + # ``model_construct`` is a classmethod that allocates without calling the + # class, so ``BranchDispatchMixin.__new__`` never runs and it produced + # exactly that refused object. Calling the class dispatches on the document + # and returns ``CreativeManifestNamedFormatManifest``, which is the branch + # ``format_id`` selects and the one a legacy preview means. + creative_manifest = CreativeManifest(format_id=format_id, assets={}) result = await creative_agent.simple.preview_creative_legacy( request_type="single", diff --git a/tests/test_targeting_overlay_compat.py b/tests/test_targeting_overlay_compat.py index 2e6e668c2..9b0675a0d 100644 --- a/tests/test_targeting_overlay_compat.py +++ b/tests/test_targeting_overlay_compat.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from typing import Annotated, Any, get_args, get_origin +from typing import Any, get_args import pytest from pydantic import ( @@ -13,7 +13,6 @@ PrivateAttr, TypeAdapter, ValidationError, - create_model, ) from pydantic.json_schema import SkipJsonSchema @@ -246,10 +245,17 @@ def test_raw_dict_keeps_input_materialization_in_enclosing_request( def test_compatibility_union_is_explicitly_input_first_and_left_to_right( container: type[BaseModel], required: dict[str, Any] ) -> None: - annotation = container.model_fields["targeting_overlay"].annotation - assert get_origin(annotation) is Annotated - union, field, _validator = get_args(annotation) - assert get_args(union) == (GeneratedInput, SkipJsonSchema[TargetingOverlay], type(None)) + field = container.model_fields["targeting_overlay"] + assert get_args(field.annotation) == ( + GeneratedInput, + SkipJsonSchema[TargetingOverlay], + type(None), + ) + # ``union_mode`` is read off the FieldInfo's metadata, so that is what is graded. + # Unpacking the Annotated positionally and expecting a FieldInfo in slot 1 passed + # only while the field was built by hand: pydantic normalizes + # ``Field(union_mode=...)`` to ``_PydanticGeneralMetadata`` when it builds the field + # from a declaration. assert any( getattr(metadata, "union_mode", None) == "left_to_right" for metadata in field.metadata ) @@ -339,42 +345,24 @@ def test_field_serialization_schema_keeps_only_mutation_input_and_null( ] -@pytest.mark.parametrize( - "annotation", - [ - None, # Field removed entirely. - TargetingOverlay | None, # Reverted to the resolved-state schema. - GeneratedInput, # Optional shape changed. - list[GeneratedInput] | None, # Contains Input, but at the wrong level. - GeneratedInput | str | None, # Unexpected additional arm. - GeneratedInput | TargetingOverlay | None, # Already widened upstream. - ], -) -def test_targeting_patch_fails_closed_on_generated_shape_drift(annotation: Any) -> None: - from adcp.types._forward_compat import _patch_targeting_overlay - - fields = {} if annotation is None else {"targeting_overlay": (annotation, None)} - model = create_model("DriftedPackage", **fields) - original = dict(model.model_fields) - with pytest.raises(RuntimeError, match=r"DriftedPackage.targeting_overlay lost its"): - _patch_targeting_overlay(model) - assert model.model_fields == original - - -def test_targeting_patch_preserves_generated_field_metadata() -> None: - from adcp.types._forward_compat import _patch_targeting_overlay - - model = create_model( - "InputPackage", - targeting_overlay=( - GeneratedInput | None, - Field(default=None, alias="targeting", title="Targeting", description="Mutation input"), - ), - ) - _patch_targeting_overlay(model) - field = model.model_fields["targeting_overlay"] +@pytest.mark.parametrize("container,required", _CONTAINERS) +def test_mutation_field_keeps_its_generated_field_metadata( + container: type[BaseModel], required: dict[str, Any] +) -> None: + """Swapping the type must not take the generated ``Field(...)`` with it. + + ``Annotated`` flattens, so the compatibility alias sits beside the field's own + ``Field(description=...)`` rather than replacing it. This is graded on the six + production containers rather than on a ``create_model`` stand-in: the stand-in + version of this test could not have caught the canonical facades losing the + description, which is a thing that happens only on a redeclaration. + """ + field = container.model_fields["targeting_overlay"] assert field.default is None - assert field.alias == "targeting" - assert field.title == "Targeting" - assert field.description == "Mutation input" - assert type(model.model_validate({"targeting": {}}).targeting_overlay) is GeneratedInput + assert not field.is_required() + assert field.description, "the generated description did not survive the type swap" + # And the bridge still works on the model the description came from. + assert ( + type(container.model_validate({**required, "targeting_overlay": {}}).targeting_overlay) + is GeneratedInput + ) diff --git a/tests/test_targeting_overlay_errors.py b/tests/test_targeting_overlay_errors.py index 13825c562..7763edfa3 100644 --- a/tests/test_targeting_overlay_errors.py +++ b/tests/test_targeting_overlay_errors.py @@ -179,7 +179,7 @@ def test_error_order_controls_primary_field_and_message( model.model_validate_json(json.dumps(params)) else: model.model_validate(params) - error = _validation_error_to_invalid_request(task, raised.value) + error = _validation_error_to_invalid_request(task, raised.value, params) prefix = f"{collection}.0.targeting_overlay.keyword_targets.0" if mode == "json": assert error.field == f"{prefix}.unexpected" @@ -323,8 +323,9 @@ def test_shared_platform_formatter_preserves_document_error( task: str, collection: str, model: type[BaseModel] ) -> None: with pytest.raises(ValidationError) as raised: - model.model_validate(_payload(task, collection)) - error = _validation_error_to_invalid_request(task, raised.value) + payload = _payload(task, collection) + model.model_validate(payload) + error = _validation_error_to_invalid_request(task, raised.value, payload) _assert_error( { "code": error.code, @@ -371,7 +372,18 @@ class Request(BaseModel): ("choice", "SecondVariant", "value"), ] assert narrow_union_errors(errors) == errors - public_error = _validation_error_to_invalid_request("example", raised.value) - assert public_error.field == "choice.FirstVariant.value" + public_error = _validation_error_to_invalid_request( + "example", raised.value, {"choice": {"value": _SECRET}} + ) + # core/error.json: `field` is a "Field path ... in JSONPath-lite format", + # and issues[].pointer is a pointer "to the offending field in the request + # payload". `FirstVariant` is a Python class name and appears nowhere in + # {"choice": {"value": ...}}, so a buyer resolving it against what it sent + # gets nothing. This asserted "choice.FirstVariant.value" — the variant + # label inside a document path — which is the defect salesagent-8qela.28 + # is about. The label is not lost: `details.validation_errors` below keeps + # the raw locs, so which variant complained is still available. field is + # the resolvable path; details is the full diagnostic. + assert public_error.field == "choice.value" assert public_error.details == {"validation_errors": errors} assert _SECRET not in public_error.args[0] diff --git a/tests/test_type_check_fixtures.py b/tests/test_type_check_fixtures.py new file mode 100644 index 000000000..461214217 --- /dev/null +++ b/tests/test_type_check_fixtures.py @@ -0,0 +1,145 @@ +"""The adopter type-check fixtures are graded by the suite, not only by a Makefile. + +WHY THIS EXISTS. ``tests/type_checks/`` is the adopter-pattern fixture set: it +encodes what a consumer of the published types is entitled to assume, and it is +the only thing in the tree that notices a public type-contract change. But +nothing ran it. ``make typecheck`` is ``mypy src/`` only; ``make typecheck-all`` +does include the fixtures but is not what anyone runs while iterating; and the +fixtures were in no pytest suite at all, so no suite run could see them. + +The consequence, measured: c045fe617 made ``Product.format_options`` and +``SyncCreativesRequest.creatives`` optional to match the pin, left +``canonical_override_field_types.py`` asserting the old required shape, and +shipped 4 ``mypy --strict`` errors. It merged, was reported verified, and sat in +the integrated tree THROUGH a graded baseline run — invisible, because that run +is pytest and these are mypy. The author ran ``mypy src/`` and not +``mypy --strict tests/type_checks/``: the right instrument, correctly used, +pointed at the wrong target (salesagent-8qela.87). + +So the rule — a change to a public field's type or requiredness must run the +type-check fixtures — is enforced here instead of documented somewhere. The next +person to change a requiredness does not need to know it. + +THE PRECEDENT is ``tests/test_canonical_removed_fields.py``, which already spawns +mypy AND pyright on snippets and asserts both refuse a removed keyword. A pytest +test that runs a type checker is established practice in this tree; this one +simply points it at the fixture directory. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from collections import Counter +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_FIXTURE_DIR = _REPO_ROOT / "tests" / "type_checks" + +#: Measured at d518308ea: ``mypy --strict tests/type_checks/`` reports exactly one +#: error, ``creative_asset_binding.py:82``, which asserts the readback manifest's +#: ``format_kind`` is ``str | None``. Clearing it needs ``_DeliveryCreativeManifest`` +#: bound to the generated root, and binding it there makes it validate as the root's +#: UNION so a ``_DeliveryCreativeManifest`` instance is an instance of neither branch +#: class — measured, ``test_delivery_manifest_cannot_bypass_strict_input`` goes red +#: with ``type='model_type'``. That is the BranchDispatchMixin contract question, +#: tracked as salesagent-8qela.82. +#: +#: SHRINK-ONLY. This is the ratcheting-counter pattern this repo already uses, not an +#: allowlist of names: raising it admits a new, unexamined error, which is the exact +#: failure this file exists to catch. Lower it when salesagent-8qela.82 lands; never +#: raise it. +_MAX_STRICT_ERRORS = 1 # FIXME(#2255-adjacent): see salesagent-8qela.82 + +#: The file the single permitted error lives in. Pinned alongside the count because a +#: count alone would let a DIFFERENT error hide under the ceiling — a number is only +#: evidence for its own scope. +_EXPECTED_RESIDUAL_FILE = "creative_asset_binding.py" + +_ERROR_LINE = re.compile(r"^(?P<path>[^:]+):(?P<line>\d+): error: (?P<message>.*)$") + + +def _fixtures() -> list[Path]: + """Every fixture module, derived from the directory rather than listed. + + A hand-list would go stale exactly the way the gap this file closes did. + """ + return sorted(p for p in _FIXTURE_DIR.glob("*.py") if p.name != "__init__.py") + + +@pytest.fixture(scope="module") +def strict_errors() -> list[tuple[str, int, str]]: + """``mypy --strict`` over the fixture directory, as ``(file, line, message)``. + + Module-scoped: one mypy process for the whole file. Spawned the way + ``test_canonical_removed_fields`` spawns its checkers, including ``MYPYPATH``, + so the two tests agree about what they are grading. + """ + result = subprocess.run( + [sys.executable, "-m", "mypy", "--strict", str(_FIXTURE_DIR)], + cwd=_REPO_ROOT, + env=dict(os.environ, MYPYPATH=str(_REPO_ROOT / "src")), + capture_output=True, + text=True, + timeout=600, + ) + found = [] + for raw in result.stdout.splitlines(): + match = _ERROR_LINE.match(raw) + if match: + found.append((Path(match["path"]).name, int(match["line"]), match["message"])) + assert found or "Success" in result.stdout, ( + "mypy produced neither an error line nor a success banner, so this test is " + f"grading nothing. stdout:\n{result.stdout[-2000:]}\nstderr:\n{result.stderr[-2000:]}" + ) + return found + + +def test_the_fixture_population_is_derived_and_not_empty() -> None: + """The directory resolves, so the test below is checking something.""" + names = [p.name for p in _fixtures()] + assert len(names) >= 40, ( + f"only {len(names)} fixture modules found under {_FIXTURE_DIR}, expected at " + "least 40 — the directory has moved and this file no longer grades the " + "adopter surface" + ) + assert "canonical_override_field_types.py" in names, ( + "canonical_override_field_types.py is the module whose staleness this file " + f"exists to catch and it is absent from the derived population: {names}" + ) + + +def test_the_adopter_fixtures_typecheck_under_strict( + strict_errors: list[tuple[str, int, str]], +) -> None: + """No NEW ``mypy --strict`` error in the adopter fixtures. + + This is the assertion c045fe617 needed and did not have. A change to a public + field's type or requiredness that leaves a fixture asserting the old shape + fails here, in the suite, rather than in a Makefile target nobody invoked. + """ + unexpected = [e for e in strict_errors if e[0] != _EXPECTED_RESIDUAL_FILE] + assert unexpected == [], ( + "mypy --strict reports errors in adopter fixtures other than the one known " + f"residual in {_EXPECTED_RESIDUAL_FILE}:\n" + + "\n".join(f" {name}:{line}: {message}" for name, line, message in unexpected) + + "\n\nThese fixtures encode what a consumer of the published types may " + "assume. If a public type changed on purpose, update the fixture AND say in " + "the commit why the assertion it carried is now stale. If it changed by " + "accident, this is the regression." + ) + assert len(strict_errors) <= _MAX_STRICT_ERRORS, ( + f"mypy --strict reports {len(strict_errors)} errors in the adopter fixtures, " + f"over the shrink-only ceiling of {_MAX_STRICT_ERRORS}:\n" + + "\n".join(f" {name}:{line}: {message}" for name, line, message in strict_errors) + + f"\n\nThe ceiling exists for one residual in {_EXPECTED_RESIDUAL_FILE} " + "(salesagent-8qela.82). Do not raise it — a higher ceiling admits an " + "unexamined error, which is what this file exists to prevent." + ) + assert Counter(name for name, _, _ in strict_errors)[_EXPECTED_RESIDUAL_FILE] == len( + strict_errors + ) diff --git a/tests/test_type_coercion.py b/tests/test_type_coercion.py index 8b2e79c39..300778f80 100644 --- a/tests/test_type_coercion.py +++ b/tests/test_type_coercion.py @@ -628,6 +628,7 @@ class ExtendedProduct(Product): # No cast() needed! response = GetProductsResponse( products=[product], # type: ignore[list-item] # Ignoring due to Python list covariance limitation + cache_scope="public", ) assert len(response.products) == 1 @@ -659,6 +660,7 @@ class ExtendedError(CoreError): response = GetProductsResponse( products=[], errors=[error], # type: ignore[list-item] # Ignoring due to Python list covariance limitation + cache_scope="public", ) assert len(response.errors) == 1 diff --git a/tests/test_type_guards.py b/tests/test_type_guards.py index 958f280c6..e23a9a7bc 100644 --- a/tests/test_type_guards.py +++ b/tests/test_type_guards.py @@ -111,21 +111,28 @@ def test_nested_failed_results_are_errors_for_real_models(self) -> None: SyncReportingReceiptsResponse, ) + # Both principal responses supply ``status``: their schema roots state + # ``if result.kind == "failed" then status in ["failed","rejected"]``, + # and the generated envelope's ergonomic default is ``completed``, so a + # failed result that omits it is a document no conformant server sends. + # ``rejected`` is the value the SDK itself uses for a failed result. responses = [ GetPrincipalResponse.model_validate( { + "status": "rejected", "result": { "kind": "failed", "errors": [{"code": "UNAUTHORIZED", "message": "Unauthorized"}], - } + }, } ), SyncPrincipalResponse.model_validate( { + "status": "rejected", "result": { "kind": "failed", "errors": [{"code": "CONFLICT", "message": "Version conflict"}], - } + }, } ), SyncReportingReceiptsResponse.model_validate( diff --git a/tests/test_typed_handler_params.py b/tests/test_typed_handler_params.py index 7dcf6acc6..0aae57281 100644 --- a/tests/test_typed_handler_params.py +++ b/tests/test_typed_handler_params.py @@ -347,7 +347,7 @@ async def get_products( # Specialized-base pattern: defensively re-validate. req = GetProductsRequest.model_validate(params) received_types.append(type(req)) - return GetProductsResponse(products=[]) + return GetProductsResponse(products=[], cache_scope="public") caller = create_tool_caller(_Agent(), "get_products") await caller({"buying_mode": "brief"}) diff --git a/tests/test_union_branch_names.py b/tests/test_union_branch_names.py new file mode 100644 index 000000000..672c301f7 --- /dev/null +++ b/tests/test_union_branch_names.py @@ -0,0 +1,434 @@ +"""Union-branch names come from the schema node, never from a position (§3). + +``scripts/union_branch_names.py`` answers one question — what is this branch called — +by the first rung that holds: title, discriminator const, full required set, fail +closed. These tests grade the answer against the bundle the pipeline actually +reads, ``schemas/cache/3.2`` (``resolve_bundle_key("3.2.1")`` -> ``3.2``), +excluding ``bundled/`` (self-contained by design) and ``mcp/`` (a per-profile +tree whose duplicates inflate any count taken over it). + +The counts are pinned by EQUALITY, the way the version-envelope residue is: a +schema bump that moves one fails here and is read, rather than drifting under a +"greater than" that nobody revisits. They are measurements, not a ledger of +tolerated numbers — nothing is permitted by appearing in them. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from adcp._version import _read_packaged_version +from adcp.validation.version import resolve_bundle_key +from scripts.schema_constraints import Branch, Restated +from scripts.union_branch_names import ( + BranchName, + UnnameableBranchError, + branch_class_names, + classify, + classify_bundle, + classify_document, + derive, + pascal_case, + schema_files, + tally, + unnameable, +) + + +def _schema_dir() -> Path: + return Path("schemas") / "cache" / resolve_bundle_key(_read_packaged_version()) + + +@pytest.fixture(scope="module") +def bundle() -> list[BranchName]: + return classify_bundle(_schema_dir()) + + +def _names(relative: str) -> list[BranchName]: + path = _schema_dir() / relative + return classify_document(json.loads(path.read_text()), Path(relative)) + + +def test_the_bundle_read_is_the_bundle_generation_reads() -> None: + """A count over the wrong tree is the trap this records against.""" + assert _schema_dir() == Path("schemas/cache/3.2") + assert len(schema_files(_schema_dir())) == 1057 + + +def test_every_root_branch_is_classified_and_the_partition_is_exact( + bundle: list[BranchName], +) -> None: + """557 root branches, partitioned with nothing left over.""" + counts = tally(bundle) + assert counts["root branches"] == 557 + assert counts["rung: title"] == 169 + assert counts["rung: const"] == 82 + assert counts["rung: required-set"] == 27 + assert counts["mints no class (ref)"] == 96 + assert counts["mints no class (scalar)"] == 10 + assert counts["mints no class (validation)"] == 172 + assert counts["rung 4: FAIL CLOSED"] == 1 + # The partition covers the population: named + class-less + refused == total. + named = counts["rung: title"] + counts["rung: const"] + counts["rung: required-set"] + classless = ( + counts["mints no class (ref)"] + + counts["mints no class (scalar)"] + + counts["mints no class (validation)"] + ) + assert named + classless + counts["rung 4: FAIL CLOSED"] == counts["root branches"] + + +def test_exactly_one_branch_in_the_pinned_bundle_is_unnameable(bundle: list[BranchName]) -> None: + """The single upstream title ARCH-ONE-SURFACE §7 costs the design. + + ``get-content-standards-response.json`` oneOf[0] is the success branch; it + carries a ``description`` and no ``title`` while its sibling pattern + (``create-media-buy-response.json``) titles all three branches. One metadata PR + with in-tree precedent, and until it lands the rule refuses the name rather + than minting a counter. + """ + refused = unnameable(bundle) + assert [item.pointer for item in refused] == [ + "content-standards/get-content-standards-response.json#/oneOf/0" + ] + + +def test_the_titled_response_branches_get_the_names_the_schema_already_carries() -> None: + """The example the whole rule exists for: 1/2/3 -> Success/Error/Submitted.""" + assert [ + (item.rung, item.name) for item in _names("media-buy/create-media-buy-response.json") + ] == [ + ("title", "CreateMediaBuySuccess"), + ("title", "CreateMediaBuyError"), + ("title", "CreateMediaBuySubmitted"), + ] + + +def test_the_const_rung_names_an_untitled_discriminated_branch() -> None: + """``core/signal-ref.json``'s three scopes, named by their pinned value.""" + assert [(item.rung, item.name) for item in _names("core/signal-ref.json")] == [ + ("const", "SignalRefProduct"), + ("const", "SignalRefDataProvider"), + ("const", "SignalRefSignalSource"), + ] + + +def test_the_required_set_rung_uses_the_full_set_not_the_difference() -> None: + """``a2ui/bound-value.json`` is the case a set-difference gets wrong. + + Five branches require ``{literalString}``, ``{literalNumber}``, + ``{literalBoolean}``, ``{path}`` and ``{literalString, path}``. Branch 4 is the + conjunction of branches 0 and 3, so its difference against its siblings is EMPTY + while the full sets are all distinct. + """ + items = _names("a2ui/bound-value.json") + assert all(item.rung == "required-set" for item in items) + assert [item.name for item in items] == [ + "A2UIBoundValueLiteralString", + "A2UIBoundValueLiteralNumber", + "A2UIBoundValueLiteralBoolean", + "A2UIBoundValuePath", + "A2UIBoundValueLiteralStringPath", + ] + + +def test_a_rung_falls_through_when_its_name_is_not_unique_among_siblings() -> None: + """``core/audience-selector.json``: three branches pin the SAME const. + + Binary, categorical and numeric signal selectors all carry + ``type: "signal"``, so the const rung names them identically. Each rung's + candidate must be unique among the siblings or the rung does not hold — + which is what keeps every rung position-free, not just rung 3. This case was + found by the fail-closed collision refusal, not by reading the bundle. + """ + items = _names("core/audience-selector.json") + assert [item.rung for item in items] == [ + "required-set", + "required-set", + "required-set", + "const", + ] + assert len({item.name for item in items}) == 4 + + +def test_a_validation_branch_is_refused_before_any_rung_runs() -> None: + """Step zero: an branch introducing no property names nothing. + + ``media-buy/create-media-buy-request.json`` anyOf branches refine + ``budget_allocation``/``packages``/``proposal_id`` — all declared on the base + — with ``required`` and ``not``. They are validation constraints, and a + public class for one would name something no buyer sends. Two of the three + even carry a ``title``, which is why the classifier runs BEFORE the rungs. + """ + items = _names("media-buy/create-media-buy-request.json") + assert len(items) == 3, "fixture assumes a three-branch root anyOf" + assert {item.kind for item in items} == {"validation"} + assert {item.name for item in items} == {None} + # anyOf[2] declares ONLY ``required`` and ``not`` — no ``type``, no + # ``properties`` — which is why a "nothing structural, therefore scalar" + # fallback cannot be the classifier's shape. + assert items[2].kind == "validation" + + +def test_the_classifier_separates_a_type_branch_from_a_refinement_of_one() -> None: + """The predicate itself, on the two shapes, with no schema file involved.""" + base = {"account": {"type": "object"}} + refinement = {"properties": {"account": {"required": ["brand", "operator"]}}} + real_branch = {"type": "object", "properties": {"media_buy_id": {"type": "string"}}} + assert classify(refinement, base) == "validation" + assert classify(real_branch, base) == "type" + assert classify({"$ref": "core/thing.json"}, base) == "ref" + assert classify({"const": "asap"}, base) == "scalar" + assert classify({"type": "string", "format": "date-time"}, base) == "scalar" + + +def test_the_rule_fails_closed_rather_than_numbering() -> None: + """A type branch with nothing to derive from yields no name at all.""" + document = {"title": "Thing", "oneOf": [{"type": "object", "properties": {"a": {}}}]} + name, rung = derive(document, document["oneOf"], 0, Path("core/thing.json")) + assert name is None + assert rung == "none" + + +def test_a_rung_whose_name_is_not_unique_does_not_hold() -> None: + """Two siblings with the SAME title: the title rung is silent for both. + + Uniqueness is a property OF the rung, not a check after it, so a tie does + not collide — it falls through. With nothing else to derive from, both branches + reach rung 4 and the build refuses them by name rather than numbering them. + """ + document = { + "title": "Thing", + "oneOf": [ + {"title": "Thing Alpha", "type": "object", "properties": {"a": {"type": "string"}}}, + {"title": "Thing Alpha", "type": "object", "properties": {"b": {"type": "string"}}}, + ], + } + items = classify_document(document, Path("core/thing.json")) + assert [item.kind for item in items] == ["type", "type"] + assert [item.name for item in items] == [None, None] + + +def test_two_branches_deriving_one_name_across_rungs_is_refused_with_both_pointers() -> None: + """The collision refusal, fired on purpose. + + Rung uniqueness is per-rung, so one branch named by its title and another named + by its const can still land on the same name. That is the case the refusal + exists for: numbering them is the defect, and the build must stop and say + which two schema nodes disagree. + """ + document = { + "title": "Thing", + "oneOf": [ + {"title": "ThingAlpha", "type": "object", "properties": {"a": {"type": "string"}}}, + { + "type": "object", + "properties": {"kind": {"const": "alpha"}, "b": {"type": "string"}}, + }, + ], + } + with pytest.raises(UnnameableBranchError) as excinfo: + classify_document(document, Path("core/thing.json")) + message = str(excinfo.value) + assert "core/thing.json#/oneOf/0" in message + assert "core/thing.json#/oneOf/1" in message + assert "ThingAlpha" in message + + +def test_pascal_case_preserves_interior_capitals() -> None: + """An branch title is often already PascalCase; ``capitalize`` would mangle it.""" + assert pascal_case("CreateMediaBuySuccess") == "CreateMediaBuySuccess" + assert pascal_case("Explicit packages with fixed allocation") == ( + "ExplicitPackagesWithFixedAllocation" + ) + assert pascal_case("data_provider") == "DataProvider" + assert pascal_case("zip_plus_four") == "ZipPlusFour" + + +def test_no_derived_name_is_positional(bundle: list[BranchName]) -> None: + """The property the whole rule exists to guarantee.""" + numbered = [item for item in bundle if item.name and item.name[-1].isdigit()] + assert numbered == [], [item.pointer for item in numbered] + + +def test_the_two_response_branches_the_rule_cannot_name_today() -> None: + """The exact residue the surface move has to decide about. + + ``post_generate_fixes.restore_response_variant_aliases`` re-emits 22 response + modules and names their branches by position + (``class_names = [f"{self.base}{index}" for index in range(1, len(branches) + 1)]``), + producing 58 numbered branch classes of which 21 are publicly bound today. The + rule names 56 of the 58. Two it does not, for two different reasons, and both + are recorded here because they are the only blockers to replacing that line: + + * ``get-content-standards-response.json`` oneOf[0] — rung 4. Needs the one + upstream title, or the class leaves the public surface as an internal + numbered helper reachable through the union alias. + * ``get-creative-features-response.json`` oneOf[0] — a bare ``$ref``. The + rule correctly says there is nothing to derive, because the branch already + names the shared class it selects. The emitter nonetheless mints a NEW + class for it, which is the defect: the union should reference the shared + class, not copy it under a positional name. + """ + standards = _names("content-standards/get-content-standards-response.json") + assert standards[0].kind == "type" + assert standards[0].name is None + + features = _names("creative/get-creative-features-response.json") + assert features[0].kind == "ref" + assert features[0].name is None + # Its siblings ARE nameable, so this is one branch's shape, not a whole schema + # the rule cannot handle. + assert [item.name for item in features[1:]] == [ + "GetCreativeFeaturesError", + "GetCreativeFeaturesSubmitted", + ] + + +# --------------------------------------------------------------------------- # +# The same rungs over Branch records, for branch classes +# --------------------------------------------------------------------------- # +# +# The rule above reads schema nodes, which is right for a bundle-wide census. +# A branch class is named from the ``Branch`` record the constraint reader already +# built, so the emitter consults no node of its own -- a second reading of the +# same node is the drift channel the one-reader rule exists to close. + + +def _branch( + *, + pointer: str, + title: str | None = None, + const: tuple[str, str] | None = None, + required: tuple[str, ...] = (), +) -> Branch: + return Branch( + pointer=pointer, + title=title, + discriminator=const, + required=required, + declares_fields=False, + condition=Restated("test"), + ) + + +def test_a_branch_takes_its_title_with_the_parent_prefix() -> None: + """A request branch's title is prose, so it says what it is only with the stem. + + A response branch's title already carries the stem + (``CreateMediaBuySuccess``), and the prefix is not doubled for it. + """ + names = branch_class_names( + "CreateMediaBuyRequest", + [ + _branch(pointer="anyOf/0", title="Explicit packages with fixed allocation"), + _branch(pointer="anyOf/1", title="Committed proposal execution"), + ], + "media-buy/create-media-buy-request.json", + ) + + assert names == ( + "CreateMediaBuyRequestExplicitPackagesWithFixedAllocation", + "CreateMediaBuyRequestCommittedProposalExecution", + ) + assert branch_class_names( + "CreateMediaBuyResponse", + [ + _branch(pointer="oneOf/0", title="CreateMediaBuySuccess"), + _branch(pointer="oneOf/1", title="CreateMediaBuyError"), + ], + "media-buy/create-media-buy-response.json", + ) == ("CreateMediaBuySuccess", "CreateMediaBuyError") + + +def test_a_rung_that_names_two_branches_the_same_falls_through() -> None: + """Uniqueness is what makes every rung position-free. + + Two branches sharing a title name nothing, so the rule drops to the next rung + rather than taking the first branch's spelling and numbering the second. + """ + names = branch_class_names( + "Thing", + [ + _branch(pointer="anyOf/0", title="Same", required=("a",)), + _branch(pointer="anyOf/1", title="Same", required=("b",)), + ], + "core/thing.json", + ) + + assert names == ("ThingA", "ThingB") + + +def test_an_branch_no_rung_names_refuses_rather_than_taking_its_position() -> None: + with pytest.raises(UnnameableBranchError, match="no rung names this branch"): + branch_class_names( + "Thing", + [_branch(pointer="anyOf/0"), _branch(pointer="anyOf/1")], + "core/thing.json", + ) + + +def test_every_partitioning_root_in_the_bundle_names_its_branches() -> None: + """The emitter's population, named end to end with zero refusals. + + Derived from the bundle rather than listed here: a schema that starts + partitioning, or stops, changes this without the test being edited. + """ + from scripts.post_generate_fixes import ( + SCHEMA_DIR, + _schema_root_class_name, + flat_root_shape, + ) + from scripts.schema_constraints import ( + bundle_document_resolver, + partitions_by_requiredness, + read_root_constraints, + root_carries_constraints, + ) + + resolve = bundle_document_resolver(SCHEMA_DIR) + named: dict[str, tuple[str, ...]] = {} + for schema_file in sorted(SCHEMA_DIR.rglob("*.json")): + relative = schema_file.relative_to(SCHEMA_DIR) + if relative.parts[0] in ("mcp", "bundled"): + continue + try: + schema = json.loads(schema_file.read_text()) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(schema, dict) or not root_carries_constraints(schema): + continue + shape = flat_root_shape(schema, relative) + if shape is None: + continue + constraints = read_root_constraints( + schema, + shape, + branches_render_as_classes=False, + schema_file=str(relative), + resolve_document=resolve, + ) + if not constraints.combinator or not partitions_by_requiredness(constraints): + continue + # The root's NAME, which the schema states. Not the class the module + # declares: after the split there is no flat class, and the shape that + # carries the properties is called <Root>Shape -- deriving branch names + # from that spells them <Root>Shape<Branch>. + parent = _schema_root_class_name(schema) + assert parent is not None + named[str(relative)] = branch_class_names(parent, constraints.branches, str(relative)) + + assert len(named) > 5, f"only {len(named)} partitioning roots found" + assert named["media-buy/create-media-buy-request.json"] == ( + "CreateMediaBuyRequestExplicitPackagesWithFixedAllocation", + "CreateMediaBuyRequestExplicitPackagesWithSellerOptimizedAllocation", + "CreateMediaBuyRequestCommittedProposalExecution", + ) + flattened = [name for names in named.values() for name in names] + assert len(flattened) == len(set(flattened)), "two branches derive the same class name" + assert not [ + name for name in flattened if name[-1].isdigit() + ], "a derived name ends in a digit, which is how a positional name reads" diff --git a/tests/test_validation_parity.py b/tests/test_validation_parity.py index a7b20d1c8..2cc3024bb 100644 --- a/tests/test_validation_parity.py +++ b/tests/test_validation_parity.py @@ -7,7 +7,7 @@ from typing import Any import pytest -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError import adcp.types as public_types from adcp import get_adcp_spec_version @@ -20,9 +20,17 @@ ) -def _pydantic_accepts(model: type[BaseModel], payload: dict[str, Any]) -> bool: +def _pydantic_accepts(model: Any, payload: dict[str, Any]) -> bool: + """Whether the public name accepts the document, however it is shaped. + + A root whose branches partition is a union over branch classes, which has no + ``model_validate`` of its own -- parsing a union is what a ``TypeAdapter`` + is for. Reaching for ``model_validate`` unconditionally is what stopped this + whole module from COLLECTING, which left the series with no acceptance gate + while every other number in the run looked fine. + """ try: - model.model_validate(payload) + TypeAdapter(model).validate_python(payload) except ValidationError: return False return True @@ -82,3 +90,114 @@ def test_high_risk_behavioral_extension_has_targeted_runtime_validator() -> None "constraints": {"kind": "budget", "max_delta_percent": 10}, } ) + + +#: The generated validator whose completeness this module grades. The +#: post-processor emits it for a schema root whose ``anyOf``/``oneOf`` branches all +#: declare ``required`` (``_root_required_groups`` in +#: ``scripts/post_generate_fixes.py``), and it checks only that one branch's +#: ``required`` names are present. A root conditional (``if``/``then``/``else``) +#: gets no such validator, which is why ``get_signals`` is outside this +#: population and its declared divergences are not graded here. +_REQUIRED_GROUP_VALIDATOR = "_require_schema_required_group" + + +def _carries_required_group_validator(model: Any) -> bool: + """Whether the generator made this root refuse a document satisfying no branch. + + Two shapes answer yes, and the question is the same for both: does the + generated model carry the root's combinator at all? + + * a flat root carries the compiled validator; + * a SPLIT root carries it as the branches' own shapes -- each branch requires what + its branch requires and types what it forbids as ``None``, and the residual + conditions are compiled onto each branch. So the refusal is there, it just + is not spelled as one validator on one class. + + Asking only the first question excluded every split root from the population + the moment the emitter was wired -- including ``create_media_buy``, which is + the root this module exists to grade. + """ + from adcp.types._split_root import branch_members + + members = branch_members(model) + if members: + return all( + _REQUIRED_GROUP_VALIDATOR in member.__pydantic_decorators__.model_validators + or _branch_shape_refuses(member) + for member in members + ) + return _REQUIRED_GROUP_VALIDATOR in model.__pydantic_decorators__.model_validators + + +def _branch_shape_refuses(branch: type[BaseModel]) -> bool: + """Whether an branch's own shape is what refuses a foreign document. + + An branch requires at least one field with no default, or types at least one + field ``None`` to forbid it. Either makes a document from another branch + unacceptable to this class without any validator running. + """ + return any( + field.is_required() or field.annotation is type(None) + for field in branch.model_fields.values() + ) + + +def _required_group_cases() -> list[dict[str, Any]]: + """Fixture cases whose model carries the emitted required-group validator. + + Derived from the live classes: membership is whether the generator put the + validator on the model, not a list of ids maintained here. + """ + return [ + case + for case in _CASES + if _carries_required_group_validator(getattr(public_types, case["model"])) + ] + + +def test_the_required_group_population_is_not_empty() -> None: + """A derivation that selects nothing passes for free.""" + cases = _required_group_cases() + assert cases, ( + "no fixture case exercises a model carrying " + f"{_REQUIRED_GROUP_VALIDATOR}; the rule below grades nothing" + ) + assert any( + not case["canonical_valid"] for case in cases + ), "every such case is schema-valid, so the validator's refusals go ungraded" + + +@pytest.mark.parametrize("case", _required_group_cases(), ids=lambda case: str(case["id"])) +def test_emitted_required_group_validator_refuses_what_the_schema_refuses( + case: dict[str, Any], +) -> None: + """The emitted validator must be the whole root constraint, not part of it. + + ``enforce_root_required_groups`` states that a presence check over each + branch's ``required`` names "is the whole constraint for ``anyOf``". Where an + branch also carries ``not``, a nested ``allOf`` or a discriminating ``const``, + it is not: the model then accepts a document satisfying no branch of its own + schema, and ``model_validate`` is what a caller reads as the contract. + + Both verdicts are computed here rather than read off the fixture, and this + rule consults no divergence ledger. Recording the difference does not + satisfy it and neither does editing a recorded outcome — only the two + validators agreeing on the document does. + """ + model = getattr(public_types, case["model"]) + canonical_accepts = _canonical_accepts(case) + pydantic_accepts = _pydantic_accepts(model, case["payload"]) + + if canonical_accepts: + assert ( + pydantic_accepts + ), f"{case['id']}: the bundled schema accepts this document and {case['model']} refuses it" + return + + assert not pydantic_accepts, ( + f"{case['id']}: the bundled schema refuses this document and " + f"{case['model']}.model_validate accepts it. The root combinator's arms " + f"carry conditions beyond their 'required' names, and " + f"{_REQUIRED_GROUP_VALIDATOR} checks only the names." + ) diff --git a/tests/type_checks/canonical_override_field_types.py b/tests/type_checks/canonical_override_field_types.py index 4703ae264..0e9980743 100644 --- a/tests/type_checks/canonical_override_field_types.py +++ b/tests/type_checks/canonical_override_field_types.py @@ -44,8 +44,12 @@ def placement_format_options(placement: Placement) -> list[str]: def product_format_options(product: Product) -> list[str]: - assert_type(product.format_options, list[Format]) - return [str(option.format_kind) for option in product.format_options] + # Optional, like ``placement.format_options`` above: core/product.json + # requires format_options in no branch unconditionally, so the public model + # stopped requiring it (salesagent-8qela.69). The old assertion encoded the + # over-requirement, not the pin. + assert_type(product.format_options, list[Format] | None) + return [str(option.format_kind) for option in product.format_options or []] def product_placements(product: Product) -> list[str]: @@ -79,8 +83,12 @@ def create_media_buy_response1_packages(response: CreateMediaBuyResponse1) -> li def sync_creatives_request_creatives(request: SyncCreativesRequest) -> list[str]: - assert_type(request.creatives, list[CreativeAsset]) - return [asset.creative_id for asset in request.creatives] + # Optional: creative/sync-creatives-request.json requires only + # [idempotency_key, account] at its root and offers creatives | assignments | + # assignment_operations as anyOf branches, so a legal request may carry + # another one (salesagent-8qela.69). + assert_type(request.creatives, list[CreativeAsset] | None) + return [asset.creative_id for asset in request.creatives or []] def list_creatives_response_creatives(response: ListCreativesResponse) -> list[str]: diff --git a/tests/type_checks/extend_targeting_input.py b/tests/type_checks/extend_targeting_input.py index 118c7dda6..7a3086400 100644 --- a/tests/type_checks/extend_targeting_input.py +++ b/tests/type_checks/extend_targeting_input.py @@ -1,7 +1,5 @@ """The exact public mutation variant supports adopter subclasses and all facades.""" -from typing import cast - from pydantic import Field from typing_extensions import assert_type @@ -25,20 +23,25 @@ class InternalTargetingInput(TargetingOverlayInput): ) updated = PackageUpdate.model_validate({"package_id": "package-1", "targeting_overlay": overlay}) -# The STATIC type is the generated declaration, ``TargetingOverlayInput | None``. -# The readback union is wider at runtime: ``_forward_compat`` widens the field -# to admit beta.14 ``TargetingOverlay`` objects, carrying the ``SkipJsonSchema`` -# arm, the left-to-right union mode and a per-model core schema with it — a -# runtime mutation no source annotation expresses, so no type checker sees it. -# These assertions previously read the wider union out of -# ``canonical_creative.pyi``, which hand-transcribed the patch result; the stub -# is deleted -- it declared 35 classes while leaving 759 of their runtime fields -# undeclared across 33 of them, 40 of those required, and declared one field the -# runtime does not have -- so the static claim is now the generated one, and the -# wider runtime union is graded by the identity assertions below and by -# tests/test_targeting_overlay_compat.py. -assert_type(created.targeting_overlay, TargetingOverlayInput | None) -assert_type(updated.targeting_overlay, TargetingOverlayInput | None) +# The static type now CARRIES the beta.14 arm: ``TargetingOverlayInput | +# TargetingOverlay | None``. This block previously asserted the narrow +# ``TargetingOverlayInput | None`` and explained that the wider union was "a +# runtime mutation no source annotation expresses, so no type checker sees it". +# That is no longer true, measured on the model rather than read off the source: +# +# upstream 65626be29 static TargetingOverlayInput | None +# runtime TargetingOverlayInput | None +# here static TargetingOverlayInput | TargetingOverlay | None +# runtime TargetingOverlayInput +# | Annotated[TargetingOverlay, SkipJsonSchema()] +# | None +# +# So the two views agree now where they used to diverge, and asserting the +# narrow union would assert the old gap back into existence. ``SkipJsonSchema`` +# is a runtime-only marker a type checker does not see, which is the one +# remaining and expected difference between the two columns. +assert_type(created.targeting_overlay, TargetingOverlayInput | TargetingOverlay | None) +assert_type(updated.targeting_overlay, TargetingOverlayInput | TargetingOverlay | None) assert_type(overlay.workflow_id, str) assert created.targeting_overlay is overlay assert updated.targeting_overlay is overlay @@ -52,7 +55,8 @@ class LegacyTargeting(TargetingOverlay): legacy_update = PackageUpdate.model_validate( {"package_id": "package-1", "targeting_overlay": legacy} ) -assert_type(legacy_update.targeting_overlay, TargetingOverlayInput | None) -# The beta.14 arm is the half the static annotation does not carry, so the -# identity check has to say so explicitly rather than read as non-overlapping. -assert cast(object, legacy_update.targeting_overlay) is legacy +assert_type(legacy_update.targeting_overlay, TargetingOverlayInput | TargetingOverlay | None) +# The ``cast(object, ...)`` this line used to need is gone with the widening it +# worked around: the annotation now carries the beta.14 arm, so a LegacyTargeting +# identity check no longer reads as non-overlapping and can be asserted directly. +assert legacy_update.targeting_overlay is legacy From 68e38c1689faf5b11b8c6e9e5794d0d6adaf35a4 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" <konstantin.mirin@gmail.com> Date: Sat, 17 Oct 2026 00:11:24 -0400 Subject: [PATCH 2/3] fix(types): bind the delivery readback manifest to the manifest root, not one arm ``_DeliveryCreativeManifest`` refined ``CreativeManifestCanonicalFormatManifest`` -- oneOf[1] of the manifest root -- while the public ``CreativeManifest`` refines the root itself. Both are reached through the SAME ``$ref`` to ``core/creative-manifest.json``: ``core/creative-variant.json`` for the input manifest, ``core/get-creative-delivery-response.json`` for the readback one. One schema node refined twice, so the two owe a document one verdict, and they gave two. The root's ``required`` is ``['assets']``; oneOf[1] additionally requires ``format_kind``. The disagreement showed up in two places, neither of them the format-kind vocabulary: * statically, ``format_kind`` read ``str`` on the readback manifest and ``str | None`` on the input one. That is the ``mypy --strict`` error ``tests/type_checks/creative_asset_binding.py:82`` carried, and the only reason the ``Test Python 3.12`` leg was red. * in the refusal of ``{"assets": {}}``: the root-bound class names the root's combinator (``type='oneOf'``), the arm-bound one reported a ``missing`` field and never reached the combinator. ``core/error.json`` requires an issue to carry a JSON Schema keyword and ``missing`` is not one. Binding the root relaxes nothing at runtime. The root's ``oneOf`` is enforced by the root's own conjunct validator, not by ``BranchDispatchMixin``'s branch union -- the mixin hands the union only to the class that DECLARES ``_branch_models``, and a refinement declared in ``canonical_creative`` does not. Measured against the input manifest on all three documents: canonical arm accepted by both, neither-arm refused ``oneOf`` by both, both-arms refused ``value_error`` by both. Note what the previous comments on this binding claimed and what is actually true. They said binding the root "would make it validate as the root's UNION"; it does not, for the reason above. The real cost is narrower: a root-bound instance is an instance of the root and of NEITHER arm, so revalidating the INSTANCE through the generated root refuses it as ``model_type`` where it used to be taken. That is the obligation #1241 asked for, and it now holds on all four (response model x input class) pairs instead of three -- so ``test_delivery_manifest_cannot_bypass_strict_input``'s conditional collapsed in the strengthening direction. Its own docstring already called the accepted case "the instance-revalidation bypass that the subclassing opened". Two new tests grade the fix rather than leaving it asserted only by mypy: ``test_readback_manifest_grades_a_document_exactly_as_the_input_manifest`` (behavioural, three documents) and ``test_readback_manifest_refines_the_manifest_root_not_one_arm`` (structural). Re-pointing the class at the arm turns 3 of those 4 cases red -- verified by doing it. The shrink-only ceiling in ``tests/test_type_check_fixtures.py`` held exactly this one error and is removed, not lowered: the residual is fixed, and the CI step it mirrors tolerates nothing either, so a ceiling could only hide something that reddens there. --- src/adcp/types/canonical_creative.py | 67 ++++++++++--- tests/test_delivery_manifest_readback.py | 122 ++++++++++++++++++++--- tests/test_type_check_fixtures.py | 61 +++++------- 3 files changed, 187 insertions(+), 63 deletions(-) diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 8ce298f41..a3de921b2 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -136,18 +136,16 @@ # canonical branch imported that branch's required ``format_kind`` onto the # public name, which is the requiredness regression on this branch # (salesagent-8qela.69): optional upstream, required here. + # + # BOTH manifest refinements below bind this: the public input manifest and the + # readback manifest on a delivery row. The pin reaches both through the same + # ``$ref`` to this root -- core/creative-variant.json for the input manifest, + # core/get-creative-delivery-response.json for the readback one -- so neither + # may narrow to one arm. The readback name carried the canonical arm until + # ``_DeliveryCreativeManifest`` below, and that is the SAME requiredness + # regression, one name over. CreativeManifest as _CreativeManifestRoot, ) -from adcp.types.domains.core.creative_manifest import ( - # The readback clone stays on the BRANCH, deliberately. Binding it to the root - # would make it validate as the root's UNION, and a ``_DeliveryCreativeManifest`` - # INSTANCE is then an instance of neither branch class -- measured: - # ``test_delivery_manifest_cannot_bypass_strict_input`` went red with - # ``type='model_type'`` against both branches. Upstream could bind the root - # because upstream's root carried no BranchDispatchMixin and so was not a union. - # That is the mixin contract question (salesagent-8qela.82), not this change. - CreativeManifestCanonicalFormatManifest as _CanonicalCreativeManifestWire, -) from adcp.types.domains.core.creative_variant import CreativeVariant as _LegacyCreativeVariant from adcp.types.domains.core.macro_resolution_capability import MacroProcessingCapability from adcp.types.domains.core.package import Package as _LegacyPackage @@ -1174,13 +1172,54 @@ class CreativeVariant(_LegacyCreativeVariant, CanonicalBoundaryModel): manifest: CreativeManifest | None = None -class _DeliveryCreativeManifest(_CanonicalCreativeManifestWire, CanonicalBoundaryModel): +class _DeliveryCreativeManifest(_CreativeManifestRoot, CanonicalBoundaryModel): """The buyer's manifest, read back off a delivery row. + **It binds the ROOT, the same node :class:`CreativeManifest` binds.** + ``core/get-creative-delivery-response.json`` reaches this field through + ``$ref .../core/creative-manifest.json``, and ``core/creative-variant.json`` + reaches the INPUT manifest through the same ``$ref``. One schema node refined + twice, so the two refinements owe a document the same verdict. + + They did not give it. This class used to bind + ``CreativeManifestCanonicalFormatManifest`` — oneOf[1] — where ``format_kind`` + is ``required``, while :class:`CreativeManifest` binds the root, whose + ``required`` is ``['assets']``. Measured, the disagreement was in two places + and neither was the vocabulary: + + * STATICALLY, ``format_kind`` read ``str`` here and ``str | None`` there. + That is the ``mypy --strict`` error ``creative_asset_binding.py:82`` + carried, and the ratchet in ``tests/test_type_check_fixtures.py`` that + held it is retired with this change. + * IN THE REFUSAL, on ``{"assets": {}}``: the root-bound class names the + root's combinator (``type='oneOf'``), the arm-bound one reported a + ``missing`` field and never reached the combinator. + ``core/error.json`` requires an issue to carry a JSON Schema keyword and + ``missing`` is not one. + + Binding the root RELAXES NOTHING at runtime. The root's ``oneOf`` is enforced + by the root's own conjunct validator, not by + :class:`~adcp.types._branch_dispatch.BranchDispatchMixin`'s branch union — + the mixin hands the union only to the class that DECLARES ``_branch_models``, + and a refinement declared here does not. Measured against the input manifest + on all three documents: canonical arm accepted by both, neither-arm refused + ``oneOf`` by both, both-arms refused ``value_error`` by both + (``test_readback_manifest_grades_a_document_exactly_as_the_input_manifest``). + + The one behavior it does move is on + ``test_delivery_manifest_cannot_bypass_strict_input``: an instance of this + class is an instance of the root and of NEITHER arm, so revalidating the + INSTANCE through the generated root's union is refused as ``model_type`` + where it used to be taken. That refusal is the obligation #1241 asked for, + and it now holds uniformly on all four (response model x input class) pairs + instead of three — a strengthening, which is why the test's conditional + collapsed rather than being relaxed. + It redeclares nothing about the format-kind vocabulary. ``format_kind`` is - ``str`` here and on :class:`CreativeManifest`, neither refuses a value, and - the ``_StrictFormatKind`` mixin that used to be the only difference between - the two is deleted. **This class is not a tolerant variant of anything.** + ``str | None`` here and on :class:`CreativeManifest`, inherited from the root + in both cases, neither refuses a value, and the ``_StrictFormatKind`` mixin + that used to be the only difference between the two is deleted. **This class + is not a tolerant variant of anything.** **The one reason it survives** is ``_normalize_readback`` below, and specifically its first line: the canonical models are real SUBCLASSES of diff --git a/tests/test_delivery_manifest_readback.py b/tests/test_delivery_manifest_readback.py index 37453c383..a491cdc5e 100644 --- a/tests/test_delivery_manifest_readback.py +++ b/tests/test_delivery_manifest_readback.py @@ -17,10 +17,14 @@ ImageContent, LegacyGetCreativeDeliveryResponse, ) +from adcp.types.canonical_creative import _DeliveryCreativeManifest from adcp.types.core import AgentConfig, Protocol, TaskResult, TaskStatus from adcp.types.domains.core.creative_manifest import ( CreativeManifest as WireCreativeManifest, ) +from adcp.types.domains.core.creative_manifest import ( + CreativeManifestCanonicalFormatManifest, +) from adcp.types.domains.core.creative_variant import ( CreativeVariant as WireCreativeVariant, ) @@ -103,16 +107,28 @@ def test_delivery_manifest_cannot_bypass_strict_input(model, input_model, reuse_ to weaken, and what this test grades is what remains true: * a dump round-trips into either input model and RETAINS the unknown kind; - * an instance is accepted or refused by ordinary pydantic class rules, not - by anything about the vocabulary — the canonical response nests a - subclass of the generated manifest, so the instance revalidates and - passes; the legacy response nests a read-back clone -- ``READBACK_CLONES`` - in ``scripts/post_generate_fixes.py``, built by ``adcp.types._readback`` - and emitted beside the class it copies -- which inherits from nothing, so - it is refused as ``model_type``. - - Deciding which kinds to accept is the caller's, through - ``adcp.types.is_canonical_format_kind``, and + * an INSTANCE is refused, on every (response model x input class) pair. + + The instance half used to be three-quarters refused and one quarter + accepted: a canonical read-back manifest handed to the generated root was + taken, because the read-back class was bound to the root's + canonical-format ARM and so was an instance of a branch the root's union + dispatches to. That binding was the bug — at 3.2.1 + ``core/get-creative-delivery-response.json`` reaches this field through + ``$ref .../core/creative-manifest.json``, the ROOT, whose ``oneOf`` offers + oneOf[0] 'Named-format manifest' as well as oneOf[1] 'Canonical-format + manifest', and an arm-bound readback type refused oneOf[0] documents + outright (``test_delivery_readback_accepts_the_named_format_arm`` below is + that regression). Binding the readback type to the root admits both arms + and makes it an instance of NEITHER, so the acceptance disappears and the + rule is uniform. **The assertion moved in the strengthening direction**: 4 + of 4 refusals where there were 3, and nothing here was relaxed to match + the code. + + Refusal is by ordinary pydantic class rules, not by anything about the + vocabulary: the value is not an instance of the class being validated + into, so it is ``model_type``. Deciding which kinds to accept is the + caller's, through ``adcp.types.is_canonical_format_kind``, and ``tests/test_open_format_kind_vocabulary.py`` grades that. """ response = model.model_validate(delivery_payload()) @@ -121,13 +137,11 @@ def test_delivery_manifest_cannot_bypass_strict_input(model, input_model, reuse_ if not reuse_instance: assert input_model.model_validate(value).format_kind == FUTURE_KIND - elif input_model is CreativeManifest or model is not GetCreativeDeliveryResponse: + else: # Not an instance of the field's class: refused structurally. with pytest.raises(ValidationError) as error: input_model.model_validate(value) assert error.value.errors()[0]["type"] == "model_type" - else: - assert input_model.model_validate(value).format_kind == FUTURE_KIND # ``assert not isinstance(manifest, input_model)`` used to stand here and is now # false by construction: phase 2 (feat/no-clones-and-arm-names) made every # canonical model a real subclass of the generated wire model it refines, so the @@ -136,6 +150,88 @@ def test_delivery_manifest_cannot_bypass_strict_input(model, input_model, reuse_ # grades the instance-revalidation bypass that the subclassing opened. +#: Documents against ``core/creative-manifest.json``'s root composition at 3.2.1, +#: with the verdict the PIN gives each. The root's ``required`` is ``['assets']`` +#: and its ``oneOf`` is a true XOR: oneOf[0] 'Named-format manifest' requires +#: ``format_id`` with ``not: {required: ['format_kind']}``, oneOf[1] +#: 'Canonical-format manifest' is the mirror. So neither key is required by the +#: root, and exactly one must be present for the document to satisfy an arm. +_MANIFEST_DOCUMENTS = [ + pytest.param({"assets": {}, "format_kind": "image"}, True, id="canonical-arm"), + pytest.param({"assets": {}}, False, id="neither-arm"), + pytest.param( + { + "assets": {}, + "format_kind": "image", + "format_id": {"id": "x", "agent_url": "https://seller.example.com"}, + }, + False, + id="both-arms", + ), +] + + +@pytest.mark.parametrize(("document", "pin_valid"), _MANIFEST_DOCUMENTS) +def test_readback_manifest_grades_a_document_exactly_as_the_input_manifest(document, pin_valid): + """The read-back manifest refines the same ``$ref`` as the input manifest. + + ``core/get-creative-delivery-response.json`` reaches + ``creatives[].variants[].manifest`` through ``$ref .../core/creative-manifest.json``, + and ``core/creative-variant.json`` reaches the input manifest through the SAME + ``$ref``. One schema node, so one verdict per document: whatever the input + manifest says about a document, the read-back manifest must say. + + It did not. The read-back class was bound to the root's canonical-format ARM, + where ``format_kind`` is ``required``, while the input class was bound to the + ROOT, which requires only ``assets``. The two then disagreed about ``format_kind`` + — statically (``str`` against ``str | None``, which is the ``mypy --strict`` + error ``creative_asset_binding.py:82`` carried) and in the REASON each gave for + refusing the ``neither-arm`` document below: the root-bound class names the + root's combinator, the arm-bound one reports a missing field and never reaches + the combinator at all. ``core/error.json`` requires an issue to carry a JSON + Schema keyword, and ``missing`` is not one. + + Binding the read-back class to the root settles both. Note what it does NOT + relax: the root's ``oneOf`` is enforced by the root's own conjunct validator, + not by ``BranchDispatchMixin``'s branch union, so a read-back manifest still + refuses the two documents the pin calls invalid. + """ + readback = _DeliveryCreativeManifest + verdicts = {} + for label, cls in (("input", CreativeManifest), ("readback", readback)): + try: + verdicts[label] = ("accepted", cls.model_validate(document).format_kind) + except ValidationError as error: + verdicts[label] = ("refused", error.errors()[0]["type"]) + + assert verdicts["input"] == verdicts["readback"], ( + "the input and read-back manifests refine the same schema node and must " + f"grade a document identically, but got {verdicts}" + ) + assert (verdicts["input"][0] == "accepted") is pin_valid, ( + f"the pin's oneOf makes this document valid={pin_valid}, got {verdicts['input']}" + ) + + +def test_readback_manifest_refines_the_manifest_root_not_one_arm(): + """``format_kind`` is optional on the read-back manifest, as on the root. + + The structural half of the test above, and the one that fails fast if the + read-back class is re-pointed at ``CreativeManifestCanonicalFormatManifest``: + that arm requires ``format_kind``, the root does not, and ``required: + ['assets']`` in ``core/creative-manifest.json`` is what settles which is + right. ``tests/type_checks/creative_asset_binding.py`` asserts the matching + static type (``str | None``) and ``tests/test_type_check_fixtures.py`` runs it. + """ + assert _DeliveryCreativeManifest.model_fields["format_kind"].is_required() is False + assert ( + _DeliveryCreativeManifest.model_fields["format_kind"].is_required() + is CreativeManifest.model_fields["format_kind"].is_required() + ) + assert WireCreativeManifest in _DeliveryCreativeManifest.__mro__ + assert CreativeManifestCanonicalFormatManifest not in _DeliveryCreativeManifest.__mro__ + + @pytest.mark.parametrize("model", RESPONSE_MODELS, ids=["canonical", "legacy"]) @pytest.mark.parametrize("input_model", [CreativeVariant, WireCreativeVariant]) def test_delivery_variant_cannot_bypass_strict_input(model, input_model): diff --git a/tests/test_type_check_fixtures.py b/tests/test_type_check_fixtures.py index 461214217..7319377d6 100644 --- a/tests/test_type_check_fixtures.py +++ b/tests/test_type_check_fixtures.py @@ -32,7 +32,6 @@ import re import subprocess import sys -from collections import Counter from pathlib import Path import pytest @@ -40,26 +39,23 @@ _REPO_ROOT = Path(__file__).resolve().parent.parent _FIXTURE_DIR = _REPO_ROOT / "tests" / "type_checks" -#: Measured at d518308ea: ``mypy --strict tests/type_checks/`` reports exactly one -#: error, ``creative_asset_binding.py:82``, which asserts the readback manifest's -#: ``format_kind`` is ``str | None``. Clearing it needs ``_DeliveryCreativeManifest`` -#: bound to the generated root, and binding it there makes it validate as the root's -#: UNION so a ``_DeliveryCreativeManifest`` instance is an instance of neither branch -#: class — measured, ``test_delivery_manifest_cannot_bypass_strict_input`` goes red -#: with ``type='model_type'``. That is the BranchDispatchMixin contract question, -#: tracked as salesagent-8qela.82. +#: THE CEILING IS GONE, AND THERE IS NOTHING TO RAISE. It used to read 1, for +#: ``creative_asset_binding.py:82`` asserting the readback manifest's ``format_kind`` +#: is ``str | None`` while ``_DeliveryCreativeManifest`` was bound to the manifest +#: root's canonical-format ARM, where that field is required ``str``. #: -#: SHRINK-ONLY. This is the ratcheting-counter pattern this repo already uses, not an -#: allowlist of names: raising it admits a new, unexamined error, which is the exact -#: failure this file exists to catch. Lower it when salesagent-8qela.82 lands; never -#: raise it. -_MAX_STRICT_ERRORS = 1 # FIXME(#2255-adjacent): see salesagent-8qela.82 - -#: The file the single permitted error lives in. Pinned alongside the count because a -#: count alone would let a DIFFERENT error hide under the ceiling — a number is only -#: evidence for its own scope. -_EXPECTED_RESIDUAL_FILE = "creative_asset_binding.py" - +#: The note that stood here said clearing it "makes it validate as the root's UNION", +#: and that was wrong on the mechanism: ``BranchDispatchMixin`` hands the branches' +#: union only to the class that DECLARES ``_branch_models``, so a refinement declared +#: in ``adcp.types.canonical_creative`` gets its own plain shape either way. The real +#: cost was narrower — a root-bound readback instance is an instance of neither arm, +#: so revalidating it THROUGH the generated root refuses it as ``model_type`` — and it +#: was a cost worth paying, because arm-binding refused ``oneOf[0]`` documents the pin +#: permits on a delivery row. Both are now settled in the readback suite, so the +#: residual is fixed rather than tolerated and the count is zero. +#: +#: Do not reintroduce a ceiling. An error in these fixtures is a public type-contract +#: change: fix the type or update the fixture and say why in the commit. _ERROR_LINE = re.compile(r"^(?P<path>[^:]+):(?P<line>\d+): error: (?P<message>.*)$") @@ -121,25 +117,18 @@ def test_the_adopter_fixtures_typecheck_under_strict( This is the assertion c045fe617 needed and did not have. A change to a public field's type or requiredness that leaves a fixture asserting the old shape fails here, in the suite, rather than in a Makefile target nobody invoked. + + ANY error, in any fixture. There is no ceiling and no permitted residual: the + one that used to be here (``creative_asset_binding.py:82``) is fixed, and the + CI step this mirrors — ``mypy --strict tests/type_checks/ ...`` on the 3.12 + leg — tolerates nothing either, so a ceiling here could only hide something + that reddens there. """ - unexpected = [e for e in strict_errors if e[0] != _EXPECTED_RESIDUAL_FILE] - assert unexpected == [], ( - "mypy --strict reports errors in adopter fixtures other than the one known " - f"residual in {_EXPECTED_RESIDUAL_FILE}:\n" - + "\n".join(f" {name}:{line}: {message}" for name, line, message in unexpected) + assert strict_errors == [], ( + "mypy --strict reports errors in the adopter type-check fixtures:\n" + + "\n".join(f" {name}:{line}: {message}" for name, line, message in strict_errors) + "\n\nThese fixtures encode what a consumer of the published types may " "assume. If a public type changed on purpose, update the fixture AND say in " "the commit why the assertion it carried is now stale. If it changed by " "accident, this is the regression." ) - assert len(strict_errors) <= _MAX_STRICT_ERRORS, ( - f"mypy --strict reports {len(strict_errors)} errors in the adopter fixtures, " - f"over the shrink-only ceiling of {_MAX_STRICT_ERRORS}:\n" - + "\n".join(f" {name}:{line}: {message}" for name, line, message in strict_errors) - + f"\n\nThe ceiling exists for one residual in {_EXPECTED_RESIDUAL_FILE} " - "(salesagent-8qela.82). Do not raise it — a higher ceiling admits an " - "unexamined error, which is what this file exists to prevent." - ) - assert Counter(name for name, _, _ in strict_errors)[_EXPECTED_RESIDUAL_FILE] == len( - strict_errors - ) From 69db120b93587c0892a47a1a72ba870c52841ffc Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" <konstantin.mirin@gmail.com> Date: Sat, 17 Oct 2026 00:11:39 -0400 Subject: [PATCH 3/3] test(reporting): grade the Core tier refusal on a schema-valid obligation ``test_core_reconciliation_refuses_a_managed_delivery_ledger`` pinned ``destination_ref`` onto a HEALTHY period and expected ``MANAGED_DELIVERY_NOT_ENABLED``. It got ``STATUS_READ_FAILED``, because ``core/reporting-obligation.json``'s ``allOf[4]`` fires on the CONJUNCTION ``if: {properties: {health: {enum: [healthy, complete]}}, required: [health, destination_ref]}`` and then requires ``materialization_count >= 1``, ``successful_materialization_count >= 1`` and ``resource_retained_until``. The seller's payload was invalid against the pin, ``load_reporting_ledger`` refused it first, and the tier check under test never ran -- so the assertion graded the read, not the tier. Supplying those counts was tried and reverted, correctly: a Core ledger HAS no materializations, so a fixture reporting one asserts something false about the very tier under test. The pin says so in ``destination_ref``'s own description -- "Core (API-delivered) obligations omit every destination and materialization field". The schema-valid, Core-legal state is to falsify ``allOf[4]``'s HEALTH conjunct instead. The obligation is now ``delayed``: the source is left unscripted, the obligation commits when the period closes, no revision exists, and the read boundary sits after ``expected_at`` but inside the declared automated recovery window -- step 8 of this module's own lifecycle narrative. Nothing is fabricated. No materialization field is required or supplied, ``allOf[8]`` wants ``issues`` non-empty and an overdue period already carries ``REPORT_OVERDUE``, and ``destination_ref`` is left as the ONE thing in the payload the pin forbids for Core. Validated against the PIN rather than against this branch's compiler -- ``jsonschema`` draft-07 over ``schemas/cache/3.2/`` with the whole cache as a ``referencing`` registry: delayed + destination_ref PIN-VALID <- the new fixture healthy + destination_ref PIN-INVALID <- the old fixture, failing on exactly allOf[4]'s three required materialization keys delayed, no destination_ref PIN-VALID <- control Two guards against a fixture that passes for the wrong reason: an assertion that the read boundary is inside the recovery window, so the obligation cannot drift to ``action_required`` unnoticed, and a reconcile of the SAME ledger through the unmodified handler, which must succeed and report ``destination_ref=None`` with ``health='delayed'``. Without the second, a fixture that broke the read for any unrelated reason would still raise and still be graded green. Removing the ``destination_ref`` injection now fails with DID NOT RAISE -- verified by doing it. --- .../test_reporting_core_lifecycle.py | 88 ++++++++++++++++--- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/tests/conformance/reporting/test_reporting_core_lifecycle.py b/tests/conformance/reporting/test_reporting_core_lifecycle.py index ae117a5c1..4ab783d80 100644 --- a/tests/conformance/reporting/test_reporting_core_lifecycle.py +++ b/tests/conformance/reporting/test_reporting_core_lifecycle.py @@ -687,12 +687,62 @@ async def test_core_reconciliation_refuses_a_managed_delivery_ledger( receipt records rather than quietly ignoring them -- a buyer that thinks it is doing Core must not end up trusting managed-delivery evidence it never verified. + + WHY THE OBLIGATION IS DELAYED AND NOT HEALTHY. ``destination_ref`` is the + field that must trip the tier check, and + ``core/reporting-obligation.json``'s ``allOf[4]`` fires on the CONJUNCTION + ``if: {properties: {health: {enum: [healthy, complete]}}, required: + [health, destination_ref]}`` -- so an obligation that is BOTH healthy and + destination-bearing must additionally carry ``materialization_count >= 1``, + ``successful_materialization_count >= 1`` and ``resource_retained_until``. + Pinning ``destination_ref`` onto a HEALTHY period therefore makes the + seller's payload invalid against the pin, ``load_reporting_ledger`` refuses + it first, and the buyer sees ``STATUS_READ_FAILED`` -- the tier check never + runs and this test grades nothing. + + Supplying those counts is the wrong repair, and was tried and reverted: a + Core ledger HAS no materializations, so a fixture that reports one asserts + something false about the tier under test. The pin says so in + ``destination_ref``'s own description -- "Core (API-delivered) obligations + omit every destination and materialization field". + + A ``delayed`` obligation makes ``allOf[4]``'s ``if`` false on the HEALTH + conjunct instead, which costs nothing and fabricates nothing: no + materialization field is required or supplied, ``allOf[8]`` wants + ``issues`` non-empty and an overdue period already carries + ``REPORT_OVERDUE``, and the obligation is a real Core obligation whose + source simply has not answered. ``destination_ref`` is then the ONE thing + in the payload the pin forbids for Core, which is exactly the single + variable this test should turn. """ source = SimulatedSource() await ledger.put_configuration(_configuration()) first = _period(0) - source.set(first.start, InlineFetchResult(rows=[], data_through=first.end)) - await _run_worker_at(ledger, source, now=first.expected_at) + # The source is left unscripted on purpose: the obligation is committed when + # the period closes, no revision exists, and the period is overdue but still + # inside the declared automated recovery window -- ``delayed``, per step 8 of + # the lifecycle above. + await _run_worker_at(ledger, source, now=first.end + timedelta(minutes=1)) + overdue = first.expected_at + timedelta(minutes=1) + assert overdue < first.expected_at + RECOVERY_WINDOW, ( + "the read boundary must sit inside the recovery window, or the obligation " + "escalates to action_required and this fixture is no longer the delayed one " + "it documents" + ) + + obligation = await ledger.find_obligation( + account_id=ACCOUNT, + consumer_id=CALLER.consumer_id, + delivery_config_id=CONFIG_ID, + delivery_config_version=1, + period_start=first.start, + period_end=first.end, + ) + assert obligation is not None + assert await _health_of(_handler_at(ledger, overdue), obligation, at=overdue) == ( + "delayed", + "REPORT_OVERDUE", + ) class ManagedHandler(ReportingStatusHandler): async def handle(self, request: dict[str, Any], *, caller: Any) -> dict[str, Any]: @@ -701,17 +751,35 @@ async def handle(self, request: dict[str, Any], *, caller: Any) -> dict[str, Any period["destination_ref"] = "dest_1" return payload - client = LedgerStatusClient(ManagedHandler(ledger), CALLER) + managed = ManagedHandler( + PgReportingLedgerStore(pool=ledger._pool, clock=lambda: overdue), # noqa: SLF001 + ) + client = LedgerStatusClient(managed, CALLER) + request = GetReportingStatusRequest.model_validate( + {"account": {"account_id": ACCOUNT}, "view": "periods"} + ) with pytest.raises(ReportingReconciliationError) as error: - await reconcile_reporting_core( - client, - GetReportingStatusRequest.model_validate( - {"account": {"account_id": ACCOUNT}, "view": "periods"} - ), - expected_periods=_expected_periods(1), - ) + await reconcile_reporting_core(client, request, expected_periods=_expected_periods(1)) assert error.value.code == "MANAGED_DELIVERY_NOT_ENABLED" + # The refusal is attributable to ``destination_ref`` and not to the delayed + # health: the SAME ledger, read through the unmodified handler, is read + # successfully and reconciles (not definitively -- the period is overdue). + # Without this, a fixture that broke the read for some unrelated reason + # would still raise and still be graded green. + clean = LedgerStatusClient( + ReportingStatusHandler( + PgReportingLedgerStore(pool=ledger._pool, clock=lambda: overdue), # noqa: SLF001 + ), + CALLER, + ) + settled = await reconcile_reporting_core(clean, request, expected_periods=_expected_periods(1)) + assert settled.definitive is False + assert [item.destination_ref for item in settled.ledger.obligations] == [None] + assert [getattr(item.health, "value", item.health) for item in settled.ledger.obligations] == [ + "delayed" + ] + async def test_the_status_projection_validates_as_adcp_wire( ledger: PgReportingLedgerStore,