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. BusinessEntity.bank and notification authentication credentials
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 secrets 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 notification authentication credentials, then 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) for config in payload.get("notification_configs") or []: if isinstance(config, dict) and isinstance(config.get("authentication"), dict): config["authentication"].pop("credentials", None) return AccountResponse.model_validate(payload)Project an internal
Accountto its response shape.Strips
billing_entity.bankand notification authentication credentials, then 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 = None notification_configs: SchemaVariant[ Annotated[list[_NotificationConfigResponse], Field(max_length=16)] | 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
- Account
- AdCPBaseModel
- pydantic.main.BaseModel
Class variables
var billing_entity : BusinessEntityResponse | Nonevar model_configvar notification_configs : list[adcp.types.projections._NotificationConfigResponse] | None
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
- BusinessEntity
- AdCPBaseModel
- pydantic.main.BaseModel
Class variables
var bank : Anyvar model_config
Inherited members
- Construction: passing