Docs/SDKs & toolsv0.0.1-preview

Python SDK

The reference TulaDB client — raw, leader-aware, pooled, and buffered clients with the full ledger surface.

On this page
  1. First transfer
  2. Client construction
  3. The write surface
  4. The read surface
  5. Handling results
  6. Decimal amounts
  7. Scaling the write path

The Python SDK is the reference implementation of the TulaDB client contract. It ships four client classes — LedgerClient, SmartLedgerClient, LedgerClientPool, and BufferedLedgerClient — covering everything from a single connection to a pooled, auto-batching write path.

Requires Python 3.9+. The package is tuladb-sdk; the import is tuladb. It is not on PyPI: the wheel is in the sdk download (verify it first). 0.0.1.dev0 is the Python packaging form of 0.0.1-preview:

pip install --no-index ./tuladb_sdk-0.0.1.dev0-py3-none-any.whl

First transfer

from tuladb import (LedgerClient, AccountCreate, AccountKey, LedgerInstance,
                    TransferCreate, LEDGER_MODE_DEFAULT)
from tuladb.protocol import make_u128

with LedgerClient("127.0.0.1", 7700) as c:
    with LedgerClient("127.0.0.1", 7800) as q:
        ledger = c.create_ledger(LedgerInstance(
            ledger_id=0,  # 0 = engine auto-allocates the next id
            currency=840,
            mode=LEDGER_MODE_DEFAULT,
        ))
        ledger_id = ledger.ledger_id

        debit = make_u128(ledger_id, 1)
        credit = make_u128(ledger_id, 2)

        c.create_account(AccountCreate(id=debit, ledger_id=ledger_id, currency=840, flags=1))
        c.create_account(AccountCreate(id=credit, ledger_id=ledger_id, currency=840, flags=2))

        result = c.transfer(TransferCreate(
            source_id=1,
            ledger_id=ledger_id,
            currency=840,
            debit_account_id=debit,
            credit_account_id=credit,
            amount=100,
            txn_id=f"py-sdk-{ledger_id}-000001",
        ))

        balance = q.get_balance(AccountKey(ledger_id=ledger_id, currency=840, id=debit))
        print(balance.available_balance)

Two clients, two ports: writes go to 7700; the second client reads from the query port 7800, which never blocks the write path.

On a node with registry_strict = true (the shipped secure profile), register the currency and the source before create_ledger, or the sample declines ERR_UNKNOWN_CURRENCY (23) and then ERR_SOURCE_UNKNOWN (24). Both calls are safe to repeat. On the default config, leave them out unless you mean to: registering the first source makes every unregistered source decline from then on.

from tuladb.models import CurrencyInstance, SourceInstance

c.create_currency(CurrencyInstance(numeric=840, alpha="USD", scale=2))
c.create_source(SourceInstance(numeric=1, alpha="APP"))

Always pass currency on a TransferCreate. Left unset, the SDK fills in ledger_id × 100 — a legacy default that names no real currency for most ledgers, so the engine would decline the transfer as a currency mismatch. The same goes for TieredTransferCreate, whose currency defaults to 101.

Accounts are addressed by an AccountKey(ledger_id, currency, id). Every account-addressed call takes one; passing a bare id raises TypeError rather than being read as something else.

Client construction

LedgerClient(
    host="127.0.0.1",
    port=7700,
    timeout=30.0,
    use_iso20022_ref_size=True,   # 35-char Base62 transfer ids; False = 64-char hex
    pipeline_depth=1,             # default in-flight frames for *_batch calls (max 64)
    tls_options=None,             # or a LedgerTlsOptions
    allow_plaintext=None,         # True = accept cleartext to a non-loopback host
)

TLS with mutual authentication:

from tuladb import LedgerClient, LedgerTlsOptions

tls = LedgerTlsOptions(
    enabled=True,
    ca_file="/run/secrets/tuladb-ca.crt",
    cert_file="/run/secrets/client.crt",
    key_file="/run/secrets/client.key",
    server_name="node1.internal",
)
with LedgerClient("node1", 7700, tls_options=tls) as c:
    c.ping()

LedgerTlsOptions.from_env() reads the TULADB_CLIENT_TLS_* environment variables, so deployments can wire certificates without code changes. The client refuses to send plaintext to a non-loopback host unless you pass allow_plaintext=True explicitly.

The write surface

transfer(t: TransferCreate) -> TransferResult
reserve(t: TransferCreate) -> TransferResult
commit(transfer_id: str) -> TransferResult
rollback(transfer_id: str) -> TransferResult
commit_by_key(source_id, ledger_id, currency, txn_id) -> TransferResult
rollback_by_key(source_id, ledger_id, currency, txn_id) -> TransferResult
reject(req: RejectCreate) -> TransferResult
reject_by_key(source_id, currency, txn_id, *, ledger_id=1, amount=0, reason_code=0, ...) -> TransferResult
tiered_transfer(t: TieredTransferCreate) -> TieredTransferResult
tiered_reserve(t: TieredTransferCreate) -> TieredTransferResult

A two-phase flow in practice:

r = c.reserve(TransferCreate(
    source_id=1, ledger_id=1, currency=840,
    debit_account_id=debit, credit_account_id=credit,
    amount=50, txn_id="order-42",
))
# ... your business decision happens here ...
c.commit_by_key(1, 1, 840, "order-42")     # or rollback_by_key(...)

The *_by_key variants recompute the transfer id from the idempotency tuple, so the service that commits doesn’t need to have seen the reserve’s response.

commit, rollback and get_transaction take the public string form (35-character Base62 by default). TransferResult.transfer_id and TxnRecord.transfer_id are not that string: they are the 32-byte wire id as an int. Convert with r.transfer_id.to_bytes(32, "little") and pass the bytes to commit_transfer_id, rollback_transfer_id or get_transaction_by_transfer_id, or get the string with transfer_id_from_bytes(...).

Registry and account administration live on the same client: create_ledger, ledger_set_max_amount(ledger_id, max_txn_amount), ledger_set_expiry(ledger_id, reserve_ttl_ns), create_source, create_currency, create_account, update_account_limit_low/high(key, …) and set_account_active(key, active). Re-creating an account with identical attributes succeeds; the same id with different attributes raises with ERR_ACCOUNT_CONFLICT (44).

The read surface

get_balance(key: AccountKey, min_batch_seq=0) -> Balance   # posted + pending, both directions
list_account_balances(ledger_id, account_id) -> list[AccountBalance]   # one row per currency
get_transaction(transfer_id) -> TxnRecord
get_transaction_by_key(source_id, ledger_id, currency, txn_id) -> TxnRecord
get_history(key: AccountKey, after_seq=0, limit=100, after_index=0) -> list[TxnRecord]
get_account_history(key: AccountKey, after_timestamp=0, limit=100)   # creation/limit events
list_accounts(offset=0, limit=256) -> tuple[int, list[AccountInfo]]   # (total, page)
list_transfers(after_seq=0, limit=256, after_index=0) -> list[TxnRecord]
list_pending_reservations(after_seq=0, limit=256, after_index=0)
list_rejected_transfers(after_seq=0, limit=256, after_index=0)
search_transactions(search: TxnSearch) -> list[TxnRecord]
get_cluster_status() -> ClusterStatus

Balance carries six fields: credits_posted, debits_posted, credits_pending, debits_pending, posted_balance, and available_balance — so holds are visible as pending state, never hidden inside a single number. min_batch_seq is a read-your-writes token: pass a write’s batch_seq and the node answers only once it has applied through it. History and listing cursors are composite: resume from the last record’s batch_seq and batch_index (as after_seq / after_index) — see pagination. BufferedLedgerClient.list_transfers takes no after_index.

Handling results

A single-op write returns a TransferResult when the item is accepted (0), a duplicate (1), or an explicit reject you asked for (41). Any other item status raises LedgerError with .status_code and .status_name set, and the declined item’s decoded result on .result:

from tuladb import LedgerError

try:
    result = c.transfer(t)
except LedgerError as e:
    if getattr(e, "status_code", None) == 2:      # ERR_LIMIT
        ...                                       # a durable decline: tell the user
    else:
        raise

Batch calls never raise for an item: each TransferResult carries its own status. Check them yourself, or let the SDK enforce it:

for r in c.transfer_batch(transfers):
    LedgerClient.raise_for_result(r)                       # raises on any decline
    LedgerClient.raise_for_result(r, allow_duplicate=False)

raise_for_result raises LedgerTxnStatusError (with .status_code and .status_name), or its subclass LedgerDuplicateTxnError for status 1 when duplicates are disallowed. LedgerClient.status_is_final(code) tells you whether a status is the same on every retry. LedgerClient.status_is_retryable(code) (also tuladb.status_is_retryable) tells you whether nothing was written and a retry with the same identity is safe. Transport and framing problems raise LedgerError / LedgerProtocolError; a protocol error resets the socket, since the stream may be desynchronised. See Errors and rejections for the full status table.

Decimal amounts

Next SDK release. These helpers are on the SDK’s feat/decimal-amounts branch and ship with the next SDK release. Every SDK passes the same shared conversion vectors; see Amounts.

from tuladb import CurrencyScales, AmountError, CurrencyNotRegistered, to_minor, format_minor

scales = CurrencyScales(c)                 # asks the engine's registry, caches found scales
amount = scales.to_minor(124, "800.00")    # 80000 — CAD's registered scale is 2
shown  = scales.format(124, 80_000)        # "800.00"

to_minor("800.000", 2)                     # AmountError(reason="too_many_decimals") — never rounded
format_minor(-1, 8)                        # "-0.00000001"

CurrencyScales keeps a found scale for good (it cannot change) and remembers “not registered” for two seconds, so a currency registered later is picked up; scales.create_currency(...) registers one and caches it at once. An unregistered currency raises CurrencyNotRegistered instead of guessing a scale. Only strings (or exact Decimals) are accepted — never floats.

Scaling the write path

For clustered deployments, SmartLedgerClient discovers the leader and retries across re-elections. It raises a final status at once and retries every other server status after a full-jitter backoff. A transport failure or “not the leader” re-routes at once the first time, then backs off the same way.

retry_attempts is the number of retries after the first attempt. The default is tuladb.RETRY_ATTEMPTS (1), so a call makes two attempts in all. retry_attempts=N gives N+1 attempts. The backoff settings take the contract defaults:

smart = SmartLedgerClient(
    nodes,
    retry_attempts=1,              # RETRY_ATTEMPTS
    retry_initial_delay_ms=50,     # RETRY_INITIAL_DELAY_MS
    retry_max_delay_ms=2000,       # RETRY_MAX_DELAY_MS
    retry_max_elapsed_ms=15000,    # RETRY_MAX_ELAPSED_MS
)

Batch replies aren’t retried. Resubmit the items whose status is retryable, under their same identities. create_ledger with ledger_id=0 (the server assigns the id) is retried only after a retryable status or a not-the-leader refusal.

LedgerClientPool shards work over several leader-aware clients, and BufferedLedgerClient turns individual transfer() calls into coalesced wire batches:

from tuladb import LedgerClientPool, BufferedLedgerClient

pool = LedgerClientPool("ledger://n1:7700,n2:7700,n3:7700", size=8, pipeline_depth=4)
buffered = BufferedLedgerClient(pool, flush_interval_ms=2.0, pipeline_depth=4)

result = buffered.transfer(transfer)   # safe from any thread

Explicit batches are also available — up to 1,024 transfers per frame, with up to 64 frames in flight:

results = c.transfer_batch(transfers, pipeline_depth=4)

Batches are safe to resubmit whole after a failure: entries that already landed return status 1 (OK_DUPLICATE), and entries that were declined answer their original decline again.