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_legacy

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.

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 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).

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
    ] = None

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.

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.

self is explicitly positional-only to allow self as a field name.

Ancestors

Class variables

var billing_entity : BusinessEntityResponse | None
var model_config
var 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 None

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.).

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.

self is explicitly positional-only to allow self as a field name.

Ancestors

Class variables

var bank : Any
var model_config

Inherited members