Metadata-Version: 2.4
Name: tlx-sdk
Version: 0.3.0
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Classifier: License :: OSI Approved :: MIT License
Requires-Dist: pytest>=7.0 ; extra == 'dev'
Requires-Dist: hypothesis>=6.0 ; extra == 'dev'
Provides-Extra: dev
License-File: LICENSE
Summary: TLX Vendor SDK — Python bindings for ETDA-compliant document exchange
Keywords: tlx,etda,e-tax,document-exchange,jwe,invoice
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

# tlx-sdk (Python)

Python SDK for the TLX document exchange platform. Built on the Rust `tlx-sdk` core
via [PyO3](https://pyo3.rs): RSA-4096 key generation, JWE encrypt/decrypt, ETDA
envelope and payload validation, canonical hashing, and the 33 platform operations
routed through the SDK Gateway.

Key generation, encryption, decryption, validation and hashing all run **in your
process**. The private key, its passphrase and the plaintext document never leave the
machine.

## Installation

```bash
pip install tlx_sdk-0.2.0-cp312-cp312-manylinux_2_28_x86_64.whl   # pick your platform
```

**Interpreter version matters.** The published wheels are built for **CPython 3.12**
only, for macOS arm64, macOS x64, manylinux x64 and manylinux aarch64. PyO3 is not
built against the stable ABI here, so any other interpreter version must build from
the sdist:

```bash
pip install tlx_sdk-0.2.0.tar.gz     # needs a Rust toolchain
```

A quick matching environment:

```bash
python3.12 -m venv .venv
.venv/bin/pip install dist/tlx_sdk-0.2.0-cp312-*.whl
```

## Start here

Two runnable examples live in [`examples/`](./examples). Example 1 needs nothing but
this package:

```bash
cd examples
python 01_convert_and_validate.py      # ERP record -> standard message -> validate
python 02_send_and_receive.py          # encrypt, send, delivery status, receive
```

Read [`examples/README.md`](./examples/README.md) for what the SDK does and does not
do — in particular, it does **not** convert ERP data into a standard document. It
recognises which dialect a payload is written in and validates it; the mapping is
yours, and `examples/erp_to_standard.py` is a worked reference of it.

## Quick start

```python
import asyncio
import json
import uuid
from datetime import datetime, timezone

import tlx_sdk

sdk = tlx_sdk.TlxSdk(
    "https://sdk-gateway.tlx.or.th",   # SDK Gateway base URL
    "tlxk_...",                        # Participant_Key from portal -> API Key
)

# 1. Keys — generated locally; only publicJwk is ever published
keys = sdk.generate_key_pair("a-strong-passphrase")

# 2. An ETDA envelope. camelCase keys; senderId/receiverId are 13-digit Thai tax IDs.
message = {
    "messageId": str(uuid.uuid4()),
    "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
    "senderId": "0105561234567",
    "receiverId": "0105569876543",
    "documentType": "PURCHASE_ORDER",
    "version": "1.0",
    "payload": {
        "sourceDocType": "PURCHASE_ORDER",   # marks the ERP_FLAT dialect
        "poNo": "PO-26-00001",
        "poDate": "2026-08-20T00:00:00.000Z",
        "poAmt": 192600,
        "products": [
            {"itemNo": 1, "prodCode": "W0002", "prodName": "Wire",
             "uomCode": "ROLL", "qty": 100, "unitPrice": 1800},
        ],
    },
}

# 3. Validate BEFORE encrypting — afterwards the payload is ciphertext and the
#    receiver is the first party who can find a problem with it.
sdk.validate_message(message)                 # envelope only
report = sdk.inspect_payload(message)         # every payload error at once
if not report["valid"]:
    for e in report["errors"]:
        print(f"{e['path']}: {e['message']}")


async def send() -> None:
    # 4. Encrypt for the RECEIVER's public key, then relay through the gateway
    partner_keys = json.loads(await sdk.list_partner_keys(message["receiverId"]))
    jwe = sdk.encrypt_message(message, json.dumps(partner_keys["data"][0]["jwk"]))
    sent = json.loads(
        await sdk.send_message(
            message["senderId"], message["receiverId"], message["documentType"], jwe
        )
    )

    # 5. Receiving: decrypt locally, hash locally, send only the hash
    plain = sdk.decrypt_message(jwe, keys["privateJwkEncrypted"], "a-strong-passphrase")
    digest = sdk.compute_canonical_hash(plain)        # JCS-SHA256
    await sdk.verify_message_hash(sent["messageId"], digest, "JCS-SHA256")


asyncio.run(send())
```

## API surface

Everything is a method on `TlxSdk`. Local operations are synchronous and return
Python objects; **network operations are `async` and resolve to a JSON string** —
`await` then `json.loads`.

| Local (synchronous) | Purpose |
|---|---|
| `generate_key_pair(passphrase)` | RSA-4096 keypair; private half returned encrypted (AES-256-GCM, PBKDF2 600k) |
| `validate_message(message)` | Envelope rules only — payload may be any JSON. Raises `ValidationError`. |
| `validate_message_strict(message)` | Envelope **and** the payload contract for its document type |
| `inspect_payload(message)` | Returns `{valid, shape, errors[], rules_source}` — every payload error at once |
| `encrypt_message(message, recipient_public_jwk)` | JWE Compact Serialization |
| `decrypt_message(jwe, private_jwk_encrypted, passphrase)` | Returns the message dict |
| `compute_canonical_hash(message)` | JCS-SHA256 over `payload`, for the hash-only verify flow |
| `install_payload_contract` / `export_payload_contract` / `installed_payload_contracts` / `clear_payload_contracts` | Override the compiled validation tables without republishing this package |

Async methods cover keys and partner keys, participant updates, the key rotation
lifecycle, send/list/get messages, delivery status and hash verification, the PULL
inbox, matchings, partner search and the full pairing lifecycle, document-type
registration, and schema lookup. Full list with signatures:
`tlx_sdk/_tlx_sdk.pyi`.

Both constructor arguments are required; a missing or empty one raises `ConfigError`
naming the field.

## Two answers that look alike

`inspect_payload` reporting `valid: True` with `rules_source: "none"` means the
payload was **not checked** — no captured payload backs that `documentType` + `shape`
pair, so no rule table had an opinion. That is not the same as correct, and
`rules_source` is the only field that separates them. `"compiled"` means the tables in
this build judged it; a version string means an installed contract did.

## Before a send will land

1. Sender and receiver must be **paired**. Accepting a pairing request is what creates
   the matching; without one the send is `403 Participants are not matched`.
2. The **receiver** must have registered that `documentType`, or the send is `422`.

## Exceptions

Importable from `tlx_sdk`: `ConfigError`, `ValidationError`, `CryptoError`,
`HttpError`, `AuthError`, `ConnectionError`, `TimeoutError`, `KeyNotFoundError`.

Note that `ConnectionError` and `TimeoutError` shadow the Python builtins of the same
name inside this namespace, so import them explicitly rather than relying on bare
`except ConnectionError`.

## Further reading

- [`examples/`](./examples) — runnable, and the reference ERP converter
- `VENDOR_GUIDE.md` in the repository root — the integrator manual (Thai)
- `docs/sdk-coverage-matrix.md` — which capability exists in which layer and language

