Module adcp.types.projections
Response-shape projections that strip write-only fields.
The AdCP spec marks certain fields as writeOnly: true — present in
requests so adopters can populate them, but MUST NOT be echoed in
responses. The clearest case is BusinessEntity.bank: IBANs, BICs,
routing numbers, and account numbers flow into the seller during account
setup and stay there. Pydantic's default serialization round-trips
everything, so an adopter who reuses an internal Account model on
the response path can leak bank details without realizing it.
The projections here type-narrow the write-only fields to None:
construction with a non-None value raises ValidationError, and the
serialization path excludes the fields regardless. Adopters opt in by
constructing the *Response variant on the response edge, or by
piping through to_account_response().
Out of scope:
GovernanceAgent.authentication(also write-only): generated nested type, separate adopter contract; track separately.reporting_bucket: NOT write-only — seller-provisioned, buyer-readable for offline delivery coordinates. Preserved as-is by the projection.
Functions
def project_geo_postal_areas(value: GeoPostalAreas | Mapping[str, Any], version: str | None) ‑> dict[str, typing.Any]-
Expand source code
def project_geo_postal_areas( value: GeoPostalAreas | Mapping[str, Any], version: str | None, ) -> dict[str, Any]: """Project postal capability declarations for the caller's AdCP version. AdCP 3.1 introduced native country-keyed postal capabilities such as ``{"US": ["zip"]}``. AdCP 3.0 clients expect the deprecated fused booleans such as ``{"us_zip": true}``. This helper lets sellers keep one typed :class:`GeoPostalAreas` declaration and serializes only the shape the caller negotiated. Native systems with no legacy 3.0 alias (currently BR ``cep``, IN ``pin``, and ZA ``postal_code``) are omitted from 3.0 projections. Legacy booleans set to ``False`` are treated as absent so projection never invents support. """ payload = _geo_postal_payload(value) if _is_native_geo_postal_version(version): projected: dict[str, list[str]] = {} for country, systems in _iter_native_postal_systems(payload): for system in systems: _append_unique(projected, country, _postal_system_value(system)) for legacy in LegacyPostalCodeSystem: if payload.get(legacy.value) is not True: continue country, system = _LEGACY_TO_NATIVE_POSTAL[legacy.value] _append_unique(projected, country, system) return projected projected_legacy: dict[str, bool] = {} for country, systems in _iter_native_postal_systems(payload): for system in systems: legacy_alias = _NATIVE_TO_LEGACY_POSTAL.get((country, _postal_system_value(system))) if legacy_alias is not None: projected_legacy[legacy_alias.value] = True for legacy in LegacyPostalCodeSystem: if payload.get(legacy.value) is True: projected_legacy[legacy.value] = True return projected_legacyProject postal capability declarations for the caller's AdCP version.
AdCP 3.1 introduced native country-keyed postal capabilities such as
{"US": ["zip"]}. AdCP 3.0 clients expect the deprecated fused booleans such as{"us_zip": true}. This helper lets sellers keep one typed :class:GeoPostalAreasdeclaration and serializes only the shape the caller negotiated.Native systems with no legacy 3.0 alias (currently BR
cep, INpin, and ZApostal_code) are omitted from 3.0 projections. Legacy booleans set toFalseare treated as absent so projection never invents support. def to_account_response(account: Account) ‑> AccountResponse-
Expand source code
def to_account_response(account: Account) -> AccountResponse: """Project an internal ``Account`` to its response shape. Strips ``billing_entity.bank`` and returns an :class:`AccountResponse`. The remaining fields (legal_name, tax_id, address, contacts, vat_id, registration_number, ext) round-trip unchanged. ``reporting_bucket``, ``governance_agents``, and other non-write-only fields are preserved. Raises: ValidationError: if the source ``Account`` fails revalidation against the response shape (other than the bank strip). """ payload = account.model_dump(mode="python") if isinstance(payload.get("billing_entity"), dict): payload["billing_entity"].pop("bank", None) return AccountResponse.model_validate(payload)Project an internal
Accountto its response shape.Strips
billing_entity.bankand returns an :class:AccountResponse. The remaining fields (legal_name, tax_id, address, contacts, vat_id, registration_number, ext) round-trip unchanged.reporting_bucket,governance_agents, and other non-write-only fields are preserved.Raises
ValidationError- if the source
Accountfails revalidation against the response shape (other than the bank strip).
Classes
class AccountResponse (**data: Any)-
Expand source code
class AccountResponse(Account): """Response projection of :class:`Account` — billing_entity is the bank-stripped variant. Use this on the response edge of any handler that returns account state (``list_accounts``, ``get_account_financials``, etc.) when your internal ``Account`` records carry bank details. For convenience, :func:`to_account_response` projects an existing ``Account`` instance to an ``AccountResponse`` and drops bank along the way. """ billing_entity: BusinessEntityResponse | None = NoneResponse projection of :class:
Account— billing_entity is the bank-stripped variant.Use this on the response edge of any handler that returns account state (
list_accounts,get_account_financials, etc.) when your internalAccountrecords carry bank details. For convenience, :func:to_account_response()projects an existingAccountinstance to anAccountResponseand drops bank along the way.Create a new model by parsing and validating input data from keyword arguments.
Raises [
ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.selfis explicitly positional-only to allowselfas a field name.Ancestors
- adcp.types.generated_poc.core.account.Account
- AdCPBaseModel
- pydantic.main.BaseModel
Class variables
var billing_entity : BusinessEntityResponse | Nonevar model_config
Inherited members
class BusinessEntityResponse (**data: Any)-
Expand source code
class BusinessEntityResponse(BusinessEntity): """Response projection of :class:`BusinessEntity` with bank details stripped. Per AdCP 3.0.x ``core/business-entity.json``: ``bank.*`` fields carry ``writeOnly: true`` and MUST NOT appear in responses. Sellers store bank coordinates and confirm receipt without echoing them. This subclass enforces the contract two ways: * Construction: passing ``bank=...`` raises ``ValidationError``. * Serialization: the field is excluded from ``model_dump()`` output even if some path mutated it post-construction (defense in depth against ``model_copy()``, idempotency replay caches, etc.). """ bank: Any = Field(default=None, exclude=True) @field_validator("bank", mode="before") @classmethod def _reject_bank(cls, v: Any) -> None: if v is not None: raise ValueError( "BusinessEntityResponse must not carry bank details — bank is " "write-only per AdCP spec. Drop the field before constructing " "a response, or use to_account_response() to strip it." ) return NoneResponse projection of :class:
BusinessEntitywith bank details stripped.Per AdCP 3.0.x
core/business-entity.json:bank.*fields carrywriteOnly: trueand MUST NOT appear in responses. Sellers store bank coordinates and confirm receipt without echoing them.This subclass enforces the contract two ways:
- Construction: passing
bank=...raisesValidationError. - Serialization: the field is excluded from
model_dump()output even if some path mutated it post-construction (defense in depth againstmodel_copy(), idempotency replay caches, etc.).
Create a new model by parsing and validating input data from keyword arguments.
Raises [
ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.selfis explicitly positional-only to allowselfas a field name.Ancestors
- adcp.types.generated_poc.core.business_entity.BusinessEntity
- AdCPBaseModel
- pydantic.main.BaseModel
Class variables
var bank : Anyvar model_config
Inherited members
- Construction: passing