How to Add a New Contract Adapter¶
Status: V1 Audience: Paxman users who want to add support for a new contract format (Avro, Protobuf, GraphQL, a vendor-specific schema, …). Related docs: EXTENDING.md §1 (the full SPI walkthrough), docs/concepts/contracts.md (what a contract is), ADR-0007 (V1 adapter set).
This guide is a focused quick-start for adding a new contract adapter to Paxman. The full SPI walkthrough is in EXTENDING.md §1; this document is a 5-minute checklist.
1. When to add a new adapter¶
Add a new adapter when:
- You want Paxman to accept a new schema language as input (Avro, Protobuf, GraphQL, …).
- You want to convert a non-supported contract origin to
CanonicalContract(ERP object model →CanonicalContract).
Do not add a new adapter when:
- The format is already supported (Pydantic, JSON Schema, Dict DSL, OpenAPI).
- You can convert your input to a supported format with a small wrapper (e.g. convert ERP → JSON Schema once, then use the JSON Schema adapter).
If the format is brand new, you will need to ship the adapter as
a separate PyPI package (paxman-<your-format>) — the Paxman
core team only accepts adapters into the main package by ADR. See
EXTENDING.md §6.
2. The ContractAdapter SPI¶
from typing import Protocol, Any
from paxman import CanonicalContract
class ContractAdapter(Protocol):
"""SPI: translate an external contract format to/from CanonicalContract."""
@property
def format_id(self) -> str:
"""Stable identifier (e.g. 'pydantic', 'json_schema:draft-2020-12')."""
...
def adapt(self, external: Any) -> CanonicalContract:
"""Translate an external contract into a CanonicalContract.
Raises:
InvalidContractError: if the external contract is invalid.
"""
...
def export(self, canonical: CanonicalContract) -> Any:
"""Translate a CanonicalContract back into the external format.
Raises:
InvalidContractError: if the canonical contract cannot be exported.
"""
...
The format_id must be stable — it identifies the adapter
across Paxman versions. Use a namespaced string
("<format>:<version>" if the format has versions, e.g.
"json_schema:draft-2020-12").
3. Step-by-step¶
3.1 Pick a format_id¶
Choose a stable identifier. Examples:
"avro:1.0""protobuf:3.21""graphql:2021""my_erp_object"
Do not include the package name in the format_id — it should
describe the format, not the implementation.
3.2 Create the adapter file¶
Create a new file, e.g. paxman_myformat/adapter.py:
import attrs
from paxman import CanonicalContract, ContractPolicy, InvalidContractError
from paxman.contract._types import Constraint
from paxman.contract.canonical import CanonicalField, MoneyValue
from paxman.protocols import ContractAdapter
from paxman.types import FieldType
@attrs.frozen(slots=True)
class MyFormatAdapter:
"""Adapter for my_format contracts."""
@property
def format_id(self) -> str:
return "my_format"
def adapt(self, external: object) -> CanonicalContract:
try:
fields_data = parse_my_format(external)
except ParseError as e:
raise InvalidContractError(
f"Failed to parse my_format: {e}",
error_code="INVALID_MY_FORMAT",
context={"line": e.line, "column": e.column},
) from e
canonical_fields = tuple(to_canonical_field(f) for f in fields_data)
return CanonicalContract(
id="my_format_contract",
version="1",
fields=canonical_fields,
constraints=(),
policies=ContractPolicy(),
)
def export(self, canonical: CanonicalContract) -> object:
return serialize_my_format(canonical.fields)
3.3 Be pure¶
The adapter must be a pure function of its input. No randomness,
no clock, no I/O. Same input → same CanonicalContract byte-for-byte.
Tests:
def test_my_format_adapter_is_pure():
adapter = MyFormatAdapter()
external = load_fixture("my_format/simple.myf")
a = adapter.adapt(external)
b = adapter.adapt(external)
assert serialize(a) == serialize(b)
3.4 Handle errors¶
Raise InvalidContractError on invalid input, with a structured
error_code and a context dict. The Executor never sees an
invalid contract.
raise InvalidContractError(
"Field 'price' has unsupported type 'currency_code'",
error_code="UNSUPPORTED_FIELD_TYPE",
context={"field": "price", "type": "currency_code"},
)
Document the error codes your adapter raises in the adapter's docstring.
3.5 Register the adapter¶
Registering with an already-registered format_id raises
InvalidContractError (the registry uses the same error type for
all registration conflicts).
3.6 Write tests¶
At minimum:
- Adaptation tests — one per V1
FieldTypeyou support. - Export round-trip —
export(adapt(X))is the same asX(within the expressible subset). - Invalid input — one test per
error_codeyou raise. - Property test —
adapt(export(adapt(X))) == adapt(X)for random validX.
def test_my_format_adapter_handles_simple_contract():
adapter = MyFormatAdapter()
external = load_fixture("my_format/simple.myf")
canonical = adapter.adapt(external)
# CanonicalContract.fields is a tuple of CanonicalField; walk it
# by .path (the dotted field path) to find a specific field.
name_field = next(f for f in canonical.fields if f.path == "name")
assert name_field.type == FieldType.STRING
assert name_field.required is True
Use paxman.testing.contracts() for property tests (it generates
random CanonicalContract instances).
3.7 Distribute¶
Publish the adapter as a separate PyPI package
(paxman-myformat) with a README that documents:
- The
format_id. - The supported V1
FieldTypeset. - The error codes raised.
- An end-to-end example.
Use the paxman- prefix to make your package discoverable. Link
to Paxman's EXTENDING.md for the SPI reference.
4. What adapters MUST do¶
- Be pure — same input → same
CanonicalContract. No I/O, no clock, no random. - Raise
InvalidContractErroron invalid input. - Preserve
MONEYsemantics if the format supports monetary values (per ADR-0004). - Map semantic tags if the format supports metadata.
- Validate before returning — the adapter's output must be a
valid
CanonicalContract(use the standard validator).
5. What adapters MUST NOT do¶
- Read raw input — adapters only see contracts, not the data being normalized.
- Invoke capabilities — adapters only translate, never plan or execute.
- Mutate global state — adapters must be safe to use concurrently.
- Embed provider secrets — adapters don't talk to providers.
6. The full SPI walkthrough¶
For the full SPI walkthrough (including a longer example, the
export() semantics, and a worked Pydantic-style adapter), see
EXTENDING.md §1.
7. See also¶
- EXTENDING.md §1 — full SPI walkthrough.
- docs/concepts/contracts.md — what a contract is in Paxman.
- ADR-0007 — V1 adapter set rationale.
- paxman.protocols — the
ContractAdapterProtocol (the SPI). - paxman.contract.canonical — the
CanonicalContractdata model. - paxman.contract.validator — the standard validator.