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

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

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

  • adcp.types.generated_poc.core.account.Account
  • AdCPBaseModel
  • pydantic.main.BaseModel

Class variables

var billing_entityBusinessEntityResponse | None
var 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 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

  • adcp.types.generated_poc.core.business_entity.BusinessEntity
  • AdCPBaseModel
  • pydantic.main.BaseModel

Class variables

var bank : Any
var model_config

Inherited members